開発部のgedokuです。
今回は図を作成するための, HaskellのeDSLを紹介したいと思います。 ついでに、eDSLの特徴、ホスト言語の機能が使えるメリットを見せてみたいと思います。
シリーズの第一弾です。
背景
Haskellは簡潔で柔軟な構文を備えていることで、eDSLのホスト言語に適していることだと思われます。 DSLというのは特定の問題を解決するために設計された言語のことです。 eDSL(embedded DSL)というのはDSLの一種で別の言語(ホスト言語)によって定義されるものです。 eDSLで書く時は実際そのホスト言語で書くということです。 Haskellで定義されたeDSLの典型例は「parsec」というパース・ライブラリです。
最近使う機会のあったdiagrams
という図作成のeDSLと、eDSLの特徴(ホスト言語が使える)を紹介したいと思います。
diagrams
はポイント、線、ベクトルといったレベルのライブラリなのですが
このシリーズで作成する図にはちょっと低レベルな概要なので、
ノード・エッジを宣言する言語をその上に書いていこうと思います。
ポイント・線の言語とノード・エッジの言語との差を埋めるには
Haskellの抽象化機能を活かします。
この記事はdiagrams
のチュートリアルではありません。diagrams
を本格的に学ぶには
次のリンクをおすすめします(英語):
目標
目指すのはこういう図です:
参考として、当図に描写されているシステムを通る情報は、ソース(下部:Other Systems)から ターゲット(上部:Client)の方向に流れています。
せっかくHaskellを使っているので簡潔で宣言型の表現でこの図を宣言したいと思います。
宣言型言語の定義
この図は(少なくとも)次のものを宣言する必要があります:
- ノード
- テキスト
- 種類
- エンド・ユーザー / 人間
- サーバー
- ストレージ
- 種類ごとに
- 色
- 形
- エッジ
- 元ノード
- 先ノード
- ラベル
- 位置 (矢先が北向きとして、矢印の右か左を指定)
もちろん、他のものも指定する必要がありますが、
理想的に、 作成時に図の内容であるものとノードの大きさやノード同士の距離などといった図の内容とは言いにくいものを、
区別して定義する方が楽なのではと思います。
図の内容を区別で定義できるのは効率的なのではと思います。
markdown
を書く時も同じく、細かいフォーマットではなく内容に集中できるように。
ノードの位置情報は内容に関わらないと、ある面から考えると言えるかもしれませんが、 個人的な考えとして、こういう図の場合は位置を変えることで内容の伝わりやすさが異なると思いますので、 内容の一部として扱います。
それでは、目指す構文の一つの候補としては:
# 複数のノードを指定 # ノードごとに一行 <ノード変数名> = (<別のノードからの相対的な位置>) <ノードタイプ> <テキスト> ... # 複数のエッジを指定 # エッジごとに <元ノード変数名> <先ノード変数名> <ラベルの位置> <ラベル> ...
なぜノードを変数に代入する必要があるかというと、たまに別のノードの定義に 前者のノードを参照することがあるからです。 変数名ではなく、ノードのラベルを使ってもいいのですが、 必ずしもノードにラベルをつけるわけではないのと 複数のノードに同じラベルをつけたいときはラベルによるの参照が曖昧になります。
今回の図だったら:
# ノードを指定 client = anywhere human "Client" lb = (below client) server "Load Balancer" srvA = (below lb) server "Server A" srvB = (below right lb) server "Server B" dbSrv = (below srvB) server "DB Server" db = (rightOf dbSrv) disk "DB" nfs = (below srvA) disk "NFS" srvC = (below nfs) server "Server C" otherSys = (below srvC) server "Other Systems" # エッジを指定 client lb right "HTTPS" lb srvA right "HTTP" lb srvB right "HTTP" srvA nfs right "R" srvB dbSrv left "TCP" srvC otherSys right "HTTP" srvC nfs left "W" srvC dbSrv left "W" dbSrv db right "R/W"
server
,disk
,human
のデザインも定義する必要がありますが、
図の本質的な内容とは別なので、区別で定義し、その構文のこだわりは
このシリーズの範囲外だと思うので、またの機会に。
ボトムアップ
上記の言語の概念要素はdiagrams
のそれよりハイレベルですね。
diagrams
ではポイント、線、矢印、図形という存在がありますが、それらの結合は
上記書いたより汎用なのでもっと複雑(で強力)です。上記の例に至るには
抽象化が必要です。
例えば、上記の言語は線を引くとき(エッジ)2つの既存のノードを指定するのに比べて、
diagrams
で線を引くときはどこからでもどこにでも引くことが出来ます。
なお、上記の言語ではエッジにラベルをつけることができ、置くところを線に対して
指定できます(矢印の右か左)が、それに比べてdiagrams
ではテキストをどこでも置くことができ、
おくところはどこでも良く、線との関係はなく独立しています。
ノードを作る
図の最上にあるClientというラベルのあるノードと始めて、 Clientのノードを作るための関数を目指してみましょう。
円
一番簡単と思われる形状は1ユニット半径のあるサークルだと思います。
myNode :: Diagram B myNode = circle 1 circle :: (TrailLike t, V t ~ V2, N t ~ n, Transformable t) => n -> t -- 'myNode'の'circle'は circle :: Double -> Diagram B
diagrams
で見せる図はDiagram a
型のある式です。
B
はバックエンドを指すんですが、この記事では気にしなくても良いです。
自分で図を表示するのであれば、mainWith
という関数を使うのは一番手っ取り早いです。
Quick Start Tutorial
を参照ください。
しかし、横テキストは縦より幅が広いので、サークルより楕円にしましょう。
myNode :: Diagram B myNode = ellipseXY 2 1 ellipseXY :: (TrailLike t, V t ~ V2, N t ~ n, Transformable t) => n -> n -> t -- 'myNode'の'ellipseXY'は ellipseXY :: Double -> Double -> Diagram B
色
次は表面に色を。
diagrams
でそのような効果を得るにはstyle
という概念要素があります。
楕円の表面に色をつけるstyle
を追加します。
myNode :: Diagram B myNode = ellipseXY 2 1 # fc lightblue
#
は&
と同じく、引数を逆にした$
です。
というのは:
ellipseXY 2 1 # fc lightblue == fc lightblue $ ellipseXY 2 1 ellipseXY 2 1 # fc lightblue == fc lightblue (ellipseXY 2 1)
$
を使うより、式の後ろ(右)にスタイルがついた方が
読みやすいので#
は多く活用しています。
&
も引数を逆にした$
ですが違いは演算子の優先順位
infixl 1 & infixl 8 #
楕円のコードに戻ると、styleはfc
という関数を使いました。
fc
の型はちょっと怖いかもしれませんが:
fc :: (InSpace V2 n a, Floating n, Typeable n, HasStyle a) => Colour Double -> a -> a
この記事はInSpace
, Floating
, Typeable
というType classesを理解しなくても大丈夫です。
HasStyle
はStyle
を適用できる型のType classです。
楕円にfc lightBlue
という関数を適用して色をつけたということ。
テキスト
次はテキストを追加してみます。
テキストも楕円と同じくDiagram B
の型をType signature付けられます
(元々は型は決まっていなくType class制限があるんですがDiagram B
以外にも色々の
型が可能です)。
次の関数を用意しました:
myNode :: Diagram B myNode = text "Rendered text" text :: String -> Diagram B
テキストのレンダリング、text
の実装の説明は記事との関係性が低いので
略させていただきます。完璧を期すためにその実装は:
text' :: PreparedFont Double -> Double -> String -> QDiagram B V2 Double Any text' f d s = (strokeP $ textSVG' (TextOpts f SVG.INSIDE_H KERN False d d) s) # lw none textNF s = text' fnt 0.8 s # fc black text :: String -> Diagram B text = frame 0.2 . textNF
それでは2つの図を作成しました:
- 楕円
- テキスト
2つの図を一つに結合するには、定番のMonoid
インスタンスを使います。
目標図の全てのノードを揃えたらもっと詳しく図の結合の仕組みを説明しますが、
とりあえず:
a :: Diagram B b :: Diagram B a <> b :: Diagram B -- 'a'が'b'の上に映る
言い表すとa
の原点(0,0)がb
の原点(0,0)の上、a
とb
の座標系が重なるように
b
をレンダリングしてそれからa
をレンダリングします。
myNode :: Diagram B myNode = text "Rendered text" <> ellipseXY 2 1 # fc lightblue
サイズは合っているように見えますが、偶然に過ぎないんです。 見せるには楕円の幅を短くしてみます。
myNode :: Diagram B myNode = text "Rendered text" <> ellipseXY 1.5 1 # fc lightblue
横半径を2から1.5に変更しました。
この図を見ると、前の図にあった楕円のサイズがたまたま合ったということが分かります。
それで目指している関数はellipseXY
のサイズをテキストのサイズから
計算するようにします。
逆に、そうもせず、手でテキストのサイズに楕円のサイズも合わせなければ:
myNode :: String -> Diagram B myNode inputTxt = text inputTxt <> ellipseXY 2 1 # fc lightblue myDiagram :: Diagram B myDiagram = myNode "Some very very very very long text"
はみ出すようになります。
今表示された図はmyNode
でしたが、
これから表示する図はmyDiagram
と名付けて、myNode
は関数扱いをします。
最小境界ボックス
テキストを包むシェイプを描くには(凸包系アルゴリズムの除いて)テキストの何等かのサイズがいる。 テキストはシェイプとして不規則なのでサイズとして何を 図るべきなのかすぐは分かりません。 当ユース・ケースに絞ると近似値として使えるのは最小境界長方形だと思います。 最小境界長方形さえあれば、その幅・高を使って、楕円に適用すればと。 はみ出すところが現れるかもしれないので安全マージンを追加する必要になると思いますが 少なくとも目安として最小境界長方形の幅・高を使えます。
幸いなことに、図の最小環境長方形を計算する関数を書く必要はありません。
diagrams
がboundingRect
という関数を提供しています。
myDiagram :: Diagram B myDiagram = boundingRect myTextDia # lc red where myTextDia = textNF "Rendered text"
しかしboundingRect
だけを表示したら、元のテキストをどれほどよく包むか分からないので、
元テキストも表示します。
myDiagram :: Diagram B myDiagram = myTextDia <> boundingRect myTextDia # lc red where myTextDia = textNF "Rendered text"
text
の代わりにtextNF
(NF
はno frameの略)使っているのは、本物の境界長方形を見せる
ためです。text
を使えばパディングがあって境界長方形は最小であるのが分かりません。
目標にしている関数の実装はtext
を使いますが取り敢えず、
上記の理由でtextNF
を使います。
楕円でテキストを囲む関数
関数はこうなっています:
textEllSimple :: (Diagram B -> Diagram B) -- ^ style -> Double -- ^ minimum width -> Double -- ^ minimum height -> Diagram B -- ^ label -> Diagram B textEllSimple style minBoxW minBoxH lbl = lbl <> shape where shape = ellipseXY ((max w minBoxW)/2) ((max h minBoxH)/2) # style (w, h) = width &&& height $ (boundingRect lbl :: Diagram B)
名前
Ell
はEllipse
の略Simple
という語尾を付けたのは、後から本物バージョンも紹介してその名前はSimple
抜きにします。
最低限 幅・高
長いテキストのサイズに楕円のサイズを合わせる必要性が確かです。 だからといって、非常に短いテキストにも合わせる必要があるわけではありません。 その方がノードのサイズをもうちょっと統一できると思います。
記事の最初に載っている目標図に出るノード例えれば:(便宜のため、下図が最上のと同じ)
Server A
とその下のDB Server
のノードはテキストの長さがちょっと異なるけど、
両方は最低限幅以下なのでそのノードの広さは統一しています。
逆にLoad Balancer
とServer B
を見合わせてみるとLoad Balancer
というテキスト
の長さは最低限幅を超えたのでそのノードの幅はServer B
のノードの幅より広くなったというわけです。
全てのノードのサイズを統一するのであれば一番長いノードの幅の広さを最低限幅にすることで 全てのノードの幅を統一させることが出来ます。 ただ、一つのノードが他のノードに比べて一段と長いラベルがあるという場合は 他のノードは無駄に広くなるというのが欠点ですね。
ellipseXY
の引数
最低限と境界長方形の最上限を2で割ったのは
ellipseXY
の引数は直径でなく半径だからです。
textEllSimple
の実例
textEllSimple
を使ってみましょう:
myDiagram :: Diagram B myDiagram = dia1 ||| strutX 2 ||| dia2 where dia1 = getDiaForText "Some very very very very long text" dia2 = getDiaForText "a" getDiaForText = textEllSimple (fc lightblue) 3 2 . text (|||) :: (InSpace V2 n a, Juxtaposable a, Semigroup a) => a -> a -> a -- 'myDiagram'の'|||'は (|||) :: Diagram B -> Diagram B -> Diagram B
textEllSimple
を使った図
これで長いテキストに合わせて、あまりにも短いテキストをパディングをつけることが出来ました。
2つの図を並べるには|||
を使いました。
正確にいうと、3つの図を横に並べました:dia1
、透明の横線、dia2
。
縦に並べるバージョンもあります:===
===
も|||
を使った例:
myDiagram :: Diagram B myDiagram = (circ red ||| circ blue) === (circ green ||| circ yellow) where circ color = circle 1 # fc color (===) :: (InSpace V2 n a, Juxtaposable a, Semigroup a) => a -> a -> a -- 'myDiagram'の'==='は (|||) :: Diagram B -> Diagram B -> Diagram B
他の図形でテキストを囲む関数
目標図に現れるノードは楕円以外にも長方形と円筒があります。
上記のtextEllSimple
を変更して、図形を指定する部分を抽象化します。
ellipseXY
の形をたどって高さと幅を受ける関数にしましょう。
textShapeSimple :: (Double -> Double -> Diagram B) -> Double -> Double -> Diagram B -> Diagram B textShapeSimple mkShape minBoxW minBoxH lbl = lbl <> shape where shape = mkShape (max w minBoxW) (max h minBoxH) (w, h) = width &&& height $ (boundingRect lbl :: Diagram B)
高さと幅からシェイプの図形を作る関数を引数として追加しました。
上記の長い・短いラベルの楕円を並べた例をtextShapeSimple
を使うように
実装を変更してみましょう:
myDiagram :: Diagram B myDiagram = dia1 ||| strutX 2 ||| dia2 where dia1 = fc lightgreen $ getDiaForText "Some very very very very long text" dia2 = fc lightskyblue $ getDiaForText "a" getDiaForText = textShapeSimple rect 3 2 . text Diagrams.TwoD rect :: (InSpace V2 n t, TrailLike t) => n -> n -> t -- 'myDiagram'の'rect'は Diagrams.TwoD rect :: Double -> Double -> Diagram B
textShapeSimple
を使った図
rect
はdiagrams
が提供している高さと幅で長方形を作る関数。
その関数をそのままtextShapeSimple
に渡すことが出来ました。
多様化のために過ぎませんが、textEllSimple
の例と違ってstyle
を引数として
渡すのではなく、textShapeSimple
の戻り値にfc
を適用して、dia1
、dia2
それぞれの
色をつけました。
次回予告
目標図まではまだ色々残っていますが、粗く言えば:
- データベース・アイコンの円筒を作る関数
- テキストを囲む関数の必要な改善
- 例えば、図形に対してテキストのレンダリングところをどう決めるかなどを 決定する(図形の最小境界長方形の真ん中は図形によって、あまり 芳しくない場合もあります)。
- 複数のノードを位置情報をもとに一つの図に結合する
- 矢印とそのラベルを作る関数
まとめ
diagrams
のライブラリの概念要素を使って直接に目標図を作れないこともないのですが、
目標図との抽象レベルの差は小さくもないと思います。
目標図を簡単に宣言する言語に近づけるにはdiagrams
の汎用を
活かして必要のない柔軟さを抽象化することで外して、より狭い言語を作ろうとします。
そのために、ホスト言語の抽象化能力が必要です。diagrams
のより原始的要素と初めて、
結合してより大きなもの出来て、それも結合することで、段々とよりハイレベルな要素
を定義できる言語に近づけていけると思います。
今回は始めたばかりなのですが、目標図と目標言語を決めて、diagrams
が提供している
楕円、色、テキストを結合して、楕円のノードの関数を作成しました。
それから楕円の部分の抽象化で、テキストと形を指定出来る、
テキストのサイズに合わせたノードを作る関数に至りました。
次回も引き続きこの路線で続けたいと思います。
採用情報
朝日ネットでは新卒採用・キャリア採用を行っております。