Ohmを使った電気事業者に特化したDSL開発

ogp

はじめに

enechain のプロダクトではエネルギーに関連する様々なデータを取り扱います。 個社毎にカスタマイズは行わずにサービス展開しておりますが、データ加工・ビジュアライズにおいて個社毎の細かなニーズに対応する必要が出てきました。

今回は、そのような細かなニーズに対応する手段として Ohm を使った DSL(以下、関数登録機能と呼ぶ)を作成した件についてお話したいと思います。

今回作ったもの

まず、今回作ったシステムを簡単に紹介します。

例えば、ユーザは以下のような計算式を自ら定義することが出来て、既存データである base_curve の各値に 0.5 が加算された target_curve を生成することが出来ます。

target_curve = base_curve + 0.5

image1

このように、自社データや enechain が提供するデータを変換し、独自のグラフを生成することが可能です。 複数データを組み合わせたり変換することにより表現の幅が増えて、多様なユーザニーズに応えることが可能になります。

以下は、実際に利用されるものに近い複雑な計算式の例です。条件分岐やエネルギードメイン特有の関数も利用可能です。

base_curve = Lag(Energy_Data, -3) + SpotMonthlyAvg(東京, -1, 20, 0, 21, 1~48) + 0.5
target_curve = if(base_curve <= 5, base_curve, 5)

詳細要件

データ変換用の計算式を定義するにあたり、以下のような要素が必要になりました。

  • 四則演算やカッコ
10 * (3 + 0.14)
  • 論理演算と条件分岐関数(if 式)
if(round(x, 3) >= 10, 3, 4)
  • enechain の顧客である電気事業者で用いる各種エネルギーデータに関わる定数や、特定期間内の平均を算出するような特殊な関数
SpotMonthlyAvg(東京, -1, 20, 0, 21, 1~48)
  • 特殊な演算子。電気事業者は時間単位で計算したい場合があるため、開始時間から終了時間を引数として渡したいケースが存在します。そのため、わかりやすい演算子としてチルダを用意しました。
1 ~ 48

技術選定

上述の要件を元に、いくつかの技術を比較検討しました。

Go Template

要件に対して、真っ先に挙がったソリューションが Go Template です。

Go Template は、非常にシンプルな構文を採用しています。テンプレートファイル内に、Go の変数や関数を埋め込んで使用することができます。また、制御構文や条件分岐などのロジックを組み込むこともできます。Go Template は、テンプレートの再利用性を高めるために、継承(テンプレートの部品化)やインクルード(テンプレートの共有化)などの機能も提供しています。

当初はユーザに我々が用意した変数や関数などを使ってもらいながら、1つのファイルを完成させ実行させる、という方針でした。

Go Template を使うメリットとして

  • 独自で構文定義をする必要がない。変数の埋め込み、ループ、条件分岐などが可能。
  • 関数登録機能で必要な関数の実装は FuncMap で柔軟に追加することができる

が挙げられます。それに対して、デメリット(技術選定から除外する理由)として

  • 構文が Go Template に縛られるため、柔軟な構文をユーザに提供できない
  • 提供したくない関数(e.g. range など)などを部分的に省くなどの制御が難しい
  • Node.js(バックエンド)との繋ぎ込みが面倒

が挙げられます。

構文を完全に管理しきれない所で思わぬ脆弱性を生んでしまうと考えたため、今回の技術選定から除外しました。

PEG.js

続いて PEG.js を紹介します。

このライブラリは JavaScript のパーサージェネレータで、後述する Ohm と同じ PEG 規則を使用してパーサーを生成します。PEG は「Parsing Expression Grammar(解析表現文法)」の略称です。

PEG.js は、簡潔な文法を採用しており、一般的なパーサージェネレータよりも簡単に使用することができます。PEG.js を使用すると、任意の形式のテキストを処理するカスタムパーサーを簡単に作成できます。PEG.js の文法に従って、構文解析器を自動的に生成することができます。

PEG.js を使うメリットとして

  • Ohm に比べて GitHub の star 数が多い
  • Ohm に比べて日本語の記事が多い(※ Qiita や Zenn における比較)

が挙げられます。それに対して、デメリットとして

  • 型ファイルが提供されてない(TypeScript で書けない)
  • Ohm に比べて公式ドキュメントの内容が豊富でない
  • BNF で書き慣れているメンバーが多く、PEG の文法で書くためのキャッチャアップに時間がかかる
  • 最後にリリースされたのが 2016 年でコミュニティが活発でない

が挙げられます。

PEG.js が下記の Ohm に比べて公式ドキュメントが豊富ではないのと、ビジュアライザなどがないのでデバッグしづらいというから技術選定から除外しました。

Ohm

続いて Ohm を紹介します。

このライブラリは PEG.js と同様にパーサージェネレータで、ohm 言語を用いてプログラミング言語のパーサー、インタープリタやコンパイラを簡単に構築することができます。

Ohm を使うメリットとして

  • シンプルにパーサーを実装できる
  • ドキュメントが豊富
  • オンラインのエディタやビジュアライザがある

が挙げられます。それに対して、デメリットとして

  • 日本語で書かれた記事が少ない

が挙げられます。

ドキュメントが豊富だったので参考にしながら簡単な四則演算のインタプリタを実装しました。 PEG.js の PEG 文法言語に比べて、Ohm で用いる ohm 文法言語は可読性がありました。他にも PEG.js は TypeScript で実装できないであったり、Semantic Action による制限や文法自体を拡張できないというデメリットがあるため、今回は Ohm に軍配が上がりました。

技術選定のまとめ

関数登録機能の実装のために、いくつかのパーサライブラリを比較検討しました。

Go Template は要件を満たすのが難しそうだったため最初に候補から外しました。続いて、PEG.js と Ohm を比べた場合、後者の方が直感的に理解しやすいという印象でした。なにより Ohm には強力なビジュアライザがありますし、拡張性が高い上にエラー処理や解析可能がため Ohm を採用することにしました。

Ohm を用いた開発

Ohm ドキュメントのトップページに書かれている特徴である「強力な文法言語」「モジュラーセマンティックアクション」「エディタとビジュアライザ」について説明します。

  • 強力な文法言語とは、Ohm がオブジェクト指向の文法拡張が可能であるため、既存の言語に新しい構文を追加することが容易になっていることを指しています
  • モジュラーセマンティックアクションとは、文法(*.ohm ファイル)とセマンティックアクション(後述するアクションディクショナリを記述した TS ファイル)のファイルを別々で記述することができるため、モジュール性、拡張性と可読性が高いと云えます
  • エディタとビジュアライザは、オンラインで文法(ohm 言語)を記述しながら、それに対してプログラムを入力して解釈可能か、どのように解釈されるのかが表示されます。TDD のように開発できるため、開発者にとっては貴重なツールであります

モジュール構成

今回、以下のようなモジュール構成にしました。

├── action-dict.ts
├── evaluate.ts
├── expressions
│   ├── algebra-expr.ts
│   ├── if-expr.ts
│   ├── index.ts
│   └── ...
├── formula-environment.ts
├── grammars
│   ├── grammar.ohm
│   ├── grammar.ohm-bundle.d.ts
│   └── grammar.ohm-bundle.js
├── index.ts
├── lib
│   ├── arithmetic.test.ts
│   ├── arithmetic.ts
│   ├── ...
│   ├── validators.test.ts
│   ├── validators.ts
│   ├── visitors.test.ts
│   └── visitors.ts
├── parse-formula.ts
└── utils
    ├── date-fns.test.ts
    ├── date-fns.ts
    ├── round.test.ts
    ├── round.ts
    └── ...

これらのモジュールの中でも特に重要なものを紹介していきたいと思います。

grammar.ohm

関数登録機能の文法を ohm 言語によって実装したファイルです。文法の詳細はこちらを参照してください。

Formula {
    EqExp
        = RelExp ("=" | "!=") RelExp -- binary
        | RelExp
    RelExp
        = AddExp ("<=" | ">=" | "<" | ">") AddExp -- binary
        | AddExp
     ...
}

action-dict.ts

アクションディクショナリと呼ばれる生成された ohm の文法に対してどのような処理をするか定義できるファイルです。演算子、関数や定数に対してクラスを返すことで、最終的に木構造のオブジェクトが得られます。

export const FormulaActionDict: ActionDict<FormulaExpr> =
  {
    ...
    AddExp_binary(left, op, right) {
      return new ArithmeticExpr(left.toAst(), right.toAst(), op.sourceString)
    },
    MulExp_binary(left, op, right) {
      return new ArithmeticExpr(left.toAst(), right.toAst(), op.sourceString)
    },
    ...
}

expressions ディレクトリ

フォーミュラ内の関数などを関数クラスで表現することでオブジェクトとして扱えます。このディレクトリではそのような1つ1つのセマンティクスのクラスを実装していきます。わかりやすいため四則演算のクラスを例示します。

再帰的に呼び出せるように FormulaExpr インターフェースを実装します。

  • eval メソッド:このオブジェクトから子のオブジェクトの eval を呼び出すことで子で評価した結果が得られます。
  • validate メソッド:eval 実行前に意図しない引数が渡されていれば、このメソッドによって検知できます。
  • children ゲッター:親オブジェクトから子オブジェクトへの参照ができます。このゲッターを用 t いることで TypeScript Compiler API の forEachChild のような visitor パターンが実装できるようになります。
export class ArithmeticExpr implements FormulaExpr {
  constructor(
    readonly left: FormulaExpr,
    readonly right: FormulaExpr,
    readonly op: string,
  ) {}

  eval(args: RuntimeArgs) {
    const left: number = this.left.eval(args)
    const right: number = this.right.eval(args)
    return arithmetic(left, right, this.op)
  }
  validate() {
    ...
  }
  get children() {
    return [this.left, this.right]
  }
}

formula-environment.ts

一言でいうとランタイムのようなクラスを実装しています。式をパースしてオブジェクトができますが、単に eval メソッドを呼び出すと状態が持つことができなかったり、外部から渡したい値を保持できないです。eval メソッドを再帰的に呼び出すときにこのランタイムオブジェクトを引数に渡すことで特殊な処理が可能になります。

Ohm を使って開発して思ったこと

Ohm のドキュメントに書かれている特徴に誇大広告はなく、関数登録機能の要件を実装する上で大きな問題はありませんでした。当初、ohm 言語の文法が複雑そうに見えましたが、実際に利用していみると思っていたよりも癖の無い書き心地でした。 強いて改善点を挙げるとすれば、組み込みルールとして単一小文字に対応する lower や単一の文字や数字に対応する alnum などが存在しますが、正の整数値や負値などを正規表現で実装して自身で管理する必要がある点が難点です。また、引数の多い関数を実装したときにアクションディレクトリの定義が伸びて可読性が下がってしまうという問題もあります。

最後に

今回実装した関数登録機能は、今後複数プロダクトで利用予定です。上記のようなモジュールの分割をしたことで、追加機能の開発が他チームにも可能になりました。初期の学習コストとして ohm 文法言語を学ぶ必要がありますが、ビジュアライザを用いることでインタラクティブに文法の正誤チェックをすることができるので複雑な要件でも耐えうるライブラリであると云えます。

私は初めてパーサージェネレータを用いた開発をしました。パースされたオブジェクトが木構造になるため、各オブジェクトに評価関数やバリデーションを実装するだけで子から親に結果が伝搬され期待通りの結果が得られるのは楽しかったです。読者の皆様も自作の言語や DSL を作る必要があれば Ohm を使ってみて下さい。

最後まで読んでいただき、ありがとうございました。

参考資料

herp.careers herp.careers https://herp.careers/v1/enechain/xSZyeyrErbFo?source=post_page-----de826ede07c0--------------------------------herp.careers