朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

Haskellで図を作成してみましょう (その1)

開発部の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を理解しなくても大丈夫です。 HasStyleStyleを適用できる型の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つの図を作成しました:

  1. 楕円
  2. テキスト

2つの図を一つに結合するには、定番のMonoidインスタンスを使います。 目標図の全てのノードを揃えたらもっと詳しく図の結合の仕組みを説明しますが、 とりあえず:

a :: Diagram B
b :: Diagram B
a <> b :: Diagram B -- 'a'が'b'の上に映る

言い表すとaの原点(0,0)がbの原点(0,0)の上、abの座標系が重なるように 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は関数扱いをします。

最小境界ボックス

テキストを包むシェイプを描くには(凸包系アルゴリズムの除いて)テキストの何等かのサイズがいる。 テキストはシェイプとして不規則なのでサイズとして何を 図るべきなのかすぐは分かりません。 当ユース・ケースに絞ると近似値として使えるのは最小境界長方形だと思います。 最小境界長方形さえあれば、その幅・高を使って、楕円に適用すればと。 はみ出すところが現れるかもしれないので安全マージンを追加する必要になると思いますが 少なくとも目安として最小境界長方形の幅・高を使えます。

幸いなことに、図の最小環境長方形を計算する関数を書く必要はありません。 diagramsboundingRectという関数を提供しています。

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の代わりにtextNFNFは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)
名前
  • EllEllipseの略
  • Simpleという語尾を付けたのは、後から本物バージョンも紹介してその名前はSimple抜きにします。
最低限 幅・高

長いテキストのサイズに楕円のサイズを合わせる必要性が確かです。 だからといって、非常に短いテキストにも合わせる必要があるわけではありません。 その方がノードのサイズをもうちょっと統一できると思います。

記事の最初に載っている目標図に出るノード例えれば:(便宜のため、下図が最上のと同じ)

目標図

Server Aとその下のDB Serverのノードはテキストの長さがちょっと異なるけど、 両方は最低限幅以下なのでそのノードの広さは統一しています。

逆にLoad BalancerServer 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

4つの円

他の図形でテキストを囲む関数

目標図に現れるノードは楕円以外にも長方形と円筒があります。 上記の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を使った図

rectdiagramsが提供している高さと幅で長方形を作る関数。 その関数をそのままtextShapeSimpleに渡すことが出来ました。

多様化のために過ぎませんが、textEllSimpleの例と違ってstyleを引数として 渡すのではなく、textShapeSimpleの戻り値にfcを適用して、dia1dia2それぞれの 色をつけました。

次回予告

目標図まではまだ色々残っていますが、粗く言えば:

  • データベース・アイコンの円筒を作る関数
  • テキストを囲む関数の必要な改善
    • 例えば、図形に対してテキストのレンダリングところをどう決めるかなどを 決定する(図形の最小境界長方形の真ん中は図形によって、あまり 芳しくない場合もあります)。
  • 複数のノードを位置情報をもとに一つの図に結合する
  • 矢印とそのラベルを作る関数

まとめ

diagramsのライブラリの概念要素を使って直接に目標図を作れないこともないのですが、 目標図との抽象レベルの差は小さくもないと思います。 目標図を簡単に宣言する言語に近づけるにはdiagramsの汎用を 活かして必要のない柔軟さを抽象化することで外して、より狭い言語を作ろうとします。

そのために、ホスト言語の抽象化能力が必要です。diagramsのより原始的要素と初めて、 結合してより大きなもの出来て、それも結合することで、段々とよりハイレベルな要素 を定義できる言語に近づけていけると思います。

今回は始めたばかりなのですが、目標図と目標言語を決めて、diagramsが提供している 楕円、色、テキストを結合して、楕円のノードの関数を作成しました。 それから楕円の部分の抽象化で、テキストと形を指定出来る、 テキストのサイズに合わせたノードを作る関数に至りました。

次回も引き続きこの路線で続けたいと思います。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。