【Graphviz】繪製流程圖

前言

日常的開發工作中,爲代碼添加註釋是代碼可維護性的一個重要方面,但是僅僅提供註釋是不夠的,特別是當系統功能越來越複雜,涉及到的模塊越來越多的時候,僅僅靠代碼就很難從宏觀的層次去理解。因此我們需要圖例的支持,圖例不僅僅包含功能之間的交互,也可以包含複雜的數據結構的示意圖,數據流向等。

但是,常用的UML建模工具,如Visio等都略顯複雜,且體積龐大。對於開發人員,特別是後臺開發人員來說,命令行,腳本纔是最友好的,而圖形界面會很大程度的限制開發效率。相對於鼠標,鍵盤纔是開發人員最好的朋友。

graphviz簡介

本文介紹一個高效而簡潔的繪圖工具graphvizgraphviz是貝爾實驗室開發的一個開源的工具包,它使用一個特定的DSL(領域特定語言): dot作爲腳本語言,然後使用佈局引擎來解析此腳本,並完成自動佈局。graphviz提供豐富的導出格式,如常用的圖片格式,SVG,PDF格式等。

graphviz中包含了衆多的佈局器:

  • dot 默認佈局方式,主要用於有向圖
  • neato 基於spring-model(又稱force-based)算法
  • twopi 徑向佈局
  • circo 圓環佈局
  • fdp 用於無向圖

graphviz的設計初衷是對有向圖/無向圖等進行自動佈局,開發人員使用dot腳本定義圖形元素,然後選擇算法進行佈局,最終導出結果。

首先,在dot腳本中定義圖的頂點和邊,頂點和邊都具有各自的屬性,比如形狀,顏色,填充模式,字體,樣式等。然後使用合適的佈局算法進行佈局。佈局算法除了繪製各個頂點和邊之外,需要儘可能的將頂點均勻的分佈在畫布上,並且儘可能的減少邊的交叉(如果交叉過多,就很難看清楚頂點之間的關係了)。所以使用graphviz的一般流程爲:

  • 定義一個圖,並向圖中添加需要的頂點和邊
  • 爲頂點和邊添加樣式
  • 使用佈局引擎進行繪製

一旦熟悉這種開發模式,就可以快速的將你的想法繪製出來。配合一個良好的編輯器(vim/emacs)等,可以極大的提高開發效率,與常見的GUI應用的所見即所得模式對應,此模式稱爲所思即所得。比如在我的機器上,使用Sublime Text 編輯dot腳本,然後將F7/Cmd-B映射爲調用dot引擎去繪製當前腳本,並打開一個新的窗口來顯示運行結果:

workspace

對於開發人員而言,經常會用到的圖形繪製可能包括:函數調用關係,一個複雜的數據結構,系統的模塊組成,抽象語法樹等。

基礎知識

graphviz包含3中元素,頂點。每個元素都可以具有各自的屬性,用來定義字體,樣式,顏色,形狀等。下面是一些簡單的示例,可以幫助我們快速的瞭解graphviz的基本用法。

第一個graphviz圖

比如,要繪製一個有向圖,包含4個節點a,b,c,d。其中a指向bbc指向d。可以定義下列腳本:

使用dot佈局方式,繪製出來的效果如下:

dot-simple

默認的頂點中的文字爲定義頂點變量的名稱,形狀爲橢圓。邊的默認樣式爲黑色實線箭頭,我們可以在腳本中做一下修改,將頂點改爲方形,邊改爲虛線

定義頂點和邊的樣式

digraph的花括號內,添加頂點和邊的新定義:

node [shape="record"]; 

edge [style="dashed"];

則繪製的效果如下:

dot-simple2

進一步修改頂點和邊樣式

進一步,我們將頂點a的顏色改爲淡綠色,並將cd的邊改爲紅色,腳本如下:

digraph abc{
  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
  c;
  d;
   
  a -> b;
  b -> d;
  c -> d [color="red"];
}

繪製的結果如下:

dot-simple3

應當注意到,頂點和邊都接受屬性的定義,形式爲在頂點和邊的定義之後加上一個由方括號括起來的key-value列表,每個key-value對由逗號隔開。如果圖中頂點和邊採用統一的風格,則可以在圖定義的首部定義nodeedge的屬性。比如上圖中,定義所有的頂點爲方框,所有的邊爲虛線,在具體的頂點和邊之後定義的屬性將覆蓋此全局屬性。如特定與a的綠色,cd的邊的紅色。

以圖片爲節點

除了顏色,節點還可以使用圖片。不過需要注意的是,在使用圖片作爲節點的時候,需要將本來的形狀設置爲none,並且將label置爲空字符串,避免出現文字對圖片的干擾。

digraph abc{
  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
  c [shape="none", image="logos/browser-icon-chrome-resized.png", label=""];
  d;
   
  a -> b;
  b -> d;
  c -> d [color="red"];
}

image-node

子圖的繪製

graphviz支持子圖,即圖中的部分節點和邊相對對立(軟件的模塊劃分經常如此)。比如,我們可以將頂點c和d歸爲一個子圖:

cd劃分到cluster_cd這個子圖中,標籤爲c and d,並添加背景色,以方便與主圖區分開,繪製結果如下:

digraph abc{

  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
 
    subgraph cluster_cd{
      label="c and d";
      bgcolor="mintcream";
      c;
      d;
    }
 
  a -> b;
  b -> d;
  c -> d [color="red"];
}

cluster

應該注意的是,子圖的名稱必須以cluster開頭,否則graphviz無法設別。

數據結構的可視化

實際開發中,經常要用到的是對複雜數據結構的描述,graphviz提供完善的機制來繪製此類圖形。

一個hash表的數據結構

比如一個hash表的內容,可能具有下列結構:

struct st_hash_type {
    int (*compare) ();
    int (*hash) ();
};

struct st_table_entry {
    unsigned int hash;
    char *key;
    char *record;
    st_table_entry *next;
};

struct st_table {
    struct st_hash_type *type;
    int num_bins; /* slot count */
    int num_entries; /* total number of entries */
    struct st_table_entry **bins; /* slot */
};

 

繪製hash表的數據結構

從代碼上看,由於結構體存在引用關係,不夠清晰,如果層次較多,則很難以記住各個結構之間的關係,我們可以通過下圖來更清楚的展示:

hash-datastruct

腳本如下:

 

digraph st2{
  fontname = "Verdana";
  fontsize = 10;
  rankdir=TB;
  
  node [fontname = "Verdana", fontsize = 10, color="skyblue", shape="record"];
  
  edge [fontname = "Verdana", fontsize = 10, color="crimson", style="solid"];
  
  st_hash_type [label="{<head>st_hash_type|(*compare)|(*hash)}"];
  st_table_entry [label="{<head>st_table_entry|hash|key|record|<next>next}"];
  st_table [label="{st_table|<type>type|num_bins|num_entries|<bins>bins}"];
  
  st_table:bins -> st_table_entry:head;
  st_table:type -> st_hash_type:head;
  st_table_entry:next -> st_table_entry:head [style="dashed", color="forestgreen"];
}

應該注意到,在頂點的形狀爲record的時候,label屬性的語法比較奇怪,但是使用起來非常靈活。比如,用豎線”|”隔開的串會在繪製出來的節點中展現爲一條分隔符。用<>括起來的串稱爲錨點,當一個節點具有多個錨點的時候,這個特性會非常有用,比如節點st_tabletype屬性指向st_hash_type,第4個屬性指向st_table_entry等,都是通過錨點來實現的。

我們發現,使用默認的dot佈局後,綠色的這條邊覆蓋了數據結構st_table_entry,並不美觀,因此可以使用別的佈局方式來重新佈局,如使用circo算法:

circo

則可以得到更加合理的佈局結果。

hash表的實例

另外,這個hash表的一個實例如下:

hash-instance

腳本如下:

digraph st{
  fontname = "Verdana";
  fontsize = 10;
  rankdir = LR;
  rotate = 90;
  
  node [ shape="record", width=.1, height=.1];
  node [fontname = "Verdana", fontsize = 10, color="skyblue", shape="record"];
  
  edge [fontname = "Verdana", fontsize = 10, color="crimson", style="solid"];
  node [shape="plaintext"];
  
  st_table [label=<
      <table border="0" cellborder="1" cellspacing="0" align="left">
      <tr>
      <td>st_table</td>
      </tr>
      <tr>
      <td>num_bins=5</td>
      </tr>
      <tr>
      <td>num_entries=3</td>
      </tr>
      <tr>
      <td port="bins">bins</td>
      </tr>
      </table>
  >];
  
  node [shape="record"];
  num_bins [label=" <b1> | <b2> | <b3> | <b4> | <b5> ", height=2];
  node[ width=2 ];
  
  entry_1 [label="{<e>st_table_entry|<next>next}"];
  entry_2 [label="{<e>st_table_entry|<next>null}"];
  entry_3 [label="{<e>st_table_entry|<next>null}"];
  
  st_table:bins -> num_bins:b1;
  num_bins:b1 -> entry_1:e;
  entry_1:next -> entry_2:e;
  num_bins:b3 -> entry_3:e;
}

 

上例中可以看到,節點的label屬性支持類似於HTML語言中的TABLE形式的定義,通過行列的數目來定義節點的形狀,從而使得節點的組成更加靈活。

軟件模塊組成圖

Apache httpd 模塊關係

httpd

在實際的開發中,隨着系統功能的完善,軟件整體的結構會越來越複雜,通常開發人員會將軟件劃分爲可理解的多個子模塊,各個子模塊通過協作,完成各種各樣的需求。

下面有個例子,是某軟件設計時的一個草稿:

idp

IDP支持層爲一個相對獨立的子系統,其中包括如數據庫管理器,配置信息管理器等模塊,另外爲了提供更大的靈活性,將很多其他的模塊抽取出來作爲外部模塊,而支持層提供一個模塊管理器,來負責加載/卸載這些外部的模塊集合。

這些模塊間的關係較爲複雜,並且有部分模塊關係密切,應歸類爲一個子系統中,上圖對應的dot腳本爲:

digraph idp_modules{

  rankdir = TB;
  fontname = "Microsoft YaHei";
  fontsize = 12;
  
  node [ fontname = "Microsoft YaHei", fontsize = 12, shape = "record" ];
  edge [ fontname = "Microsoft YaHei", fontsize = 12 ];
  
      subgraph cluster_sl{
          label="IDP支持層";
          bgcolor="mintcream";
          node [shape="Mrecord", color="skyblue", style="filled"];
          network_mgr [label="網絡管理器"];
          log_mgr [label="日誌管理器"];
          module_mgr [label="模塊管理器"];
          conf_mgr [label="配置管理器"];
          db_mgr [label="數據庫管理器"];
      };
  
      subgraph cluster_md{
          label="可插拔模塊集";
          bgcolor="lightcyan";
          node [color="chartreuse2", style="filled"];
          mod_dev [label="開發支持模塊"];
          mod_dm [label="數據建模模塊"];
          mod_dp [label="部署發佈模塊"];
      };
  
  mod_dp -> mod_dev [label="依賴..."];
  mod_dp -> mod_dm [label="依賴..."];
  mod_dp -> module_mgr [label="安裝...", color="yellowgreen", arrowhead="none"];
  mod_dev -> mod_dm [label="依賴..."];
  mod_dev -> module_mgr [label="安裝...", color="yellowgreen", arrowhead="none"];
  mod_dm -> module_mgr [label="安裝...", color="yellowgreen", arrowhead="none"];
}

 

狀態圖

有限自動機示意圖

fsm

上圖是一個簡易有限自動機,接受aa結尾的任意長度的串。其腳本定義如下:

digraph automata_0 {
  size = "8.5, 11";
  fontname = "Microsoft YaHei";
  fontsize = 10;
  
  node [shape = circle, fontname = "Microsoft YaHei", fontsize = 10];
  edge [fontname = "Microsoft YaHei", fontsize = 10];
  
  0 [ style = filled, color=lightgrey ];
  2 [ shape = doublecircle ];
  
  0 -> 2 [ label = "a " ];
  0 -> 1 [ label = "other " ];
  1 -> 2 [ label = "a " ];
  1 -> 1 [ label = "other " ];
  2 -> 2 [ label = "a " ];
  2 -> 1 [ label = "other " ];
  
  "Machine: a" [ shape = plaintext ];
}

 

形狀值爲plaintext的表示不用繪製邊框,僅展示純文本內容,這個在繪圖中,繪製指示性的文本時很有用,如上圖中的Machine: a

OSGi中模塊的生命週期圖

OSGi中,模塊具有生命週期,從安裝到卸載,可能的狀態具有已安裝,已就緒,正在啓動,已啓動,正在停止,已卸載等。如下圖所示:

osgi

對應的腳本如下:

digraph module_lc{
  rankdir=TB;
  fontname = "Microsoft YaHei";
  fontsize = 12;
  
  node [fontname = "Microsoft YaHei", fontsize = 12, shape = "Mrecord", color="skyblue", style="filled"];
  edge [fontname = "Microsoft YaHei", fontsize = 12, color="darkgreen" ];
  
  installed [label="已安裝狀態"];
  resolved [label="已就緒狀態"];
  uninstalled [label="已卸載狀態"];
  starting [label="正在啓動"];
  active [label="已激活(運行)狀態"];
  stopping [label="正在停止"];
  start [label="", shape="circle", width=0.5, fixedsize=true, style="filled", color="black"];
  
  start -> installed [label="安裝"];
  installed -> uninstalled [label="卸載"];
  installed -> resolved [label="準備"];
  installed -> installed [label="更新"];
  resolved -> installed [label="更新"];
  resolved -> uninstalled [label="卸載"];
  resolved -> starting [label="啓動"];
  starting -> active [label=""];
  active -> stopping [label="停止"];
  stopping -> resolved [label=""];
}

 

其他實例

一棵簡單的抽象語法樹(AST)

表達式 (3+4)*5 在編譯時期,會形成一棵語法樹,一邊在計算時,先計算3+4的值,最後與5相乘。

ast-calc

對應的腳本如下:

digraph ast{
  fontname = "Microsoft YaHei";
  fontsize = 10;
  
  node [shape = circle, fontname = "Microsoft YaHei", fontsize = 10];
  edge [fontname = "Microsoft YaHei", fontsize = 10];
  node [shape="plaintext"];
  
  mul [label="mul(*)"];
  add [label="add(+)"];
  
  add -> 3
  add -> 4;
  mul -> add;
  mul -> 5;
}

 

簡單的UML類圖

下面是一簡單的UML類圖,DogCat都是Animal的子類,DogCat同屬一個包,且有可能有聯繫(0..n)

uml

腳本:

 

digraph G{
  
  fontname = "Courier New"
  fontsize = 10
  
  node [ fontname = "Courier New", fontsize = 10, shape = "record" ];
  edge [ fontname = "Courier New", fontsize = 10 ];
  
  Animal [ label = "{Animal |+ name : String\l+ age : int\l|+ die() : void\l}" ];
  
      subgraph clusterAnimalImpl{
          bgcolor="yellow"
          Dog [ label = "{Dog||+ bark() : void\l}" ];
          Cat [ label = "{Cat||+ meow() : void\l}" ];
      };
  
  edge [ arrowhead = "empty" ];
  
  Dog->Animal;
  Cat->Animal;
  Dog->Cat [arrowhead="none", label="0..*"];
}

狀態圖

status-chart

腳本:

digraph finite_state_machine {
  rankdir = LR;
  size = "8,5"
  
  node [shape = doublecircle];
  
  LR_0 LR_3 LR_4 LR_8;
  
  node [shape = circle];
  
  LR_0 -> LR_2 [ label = "SS(B)" ];
  LR_0 -> LR_1 [ label = "SS(S)" ];
  LR_1 -> LR_3 [ label = "S($end)" ];
  LR_2 -> LR_6 [ label = "SS(b)" ];
  LR_2 -> LR_5 [ label = "SS(a)" ];
  LR_2 -> LR_4 [ label = "S(A)" ];
  LR_5 -> LR_7 [ label = "S(b)" ];
  LR_5 -> LR_5 [ label = "S(a)" ];
  LR_6 -> LR_6 [ label = "S(b)" ];
  LR_6 -> LR_5 [ label = "S(a)" ];
  LR_7 -> LR_8 [ label = "S(b)" ];
  LR_7 -> LR_5 [ label = "S(a)" ];
  LR_8 -> LR_6 [ label = "S(b)" ];
  LR_8 -> LR_5 [ label = "S(a)" ];
}

 

時序圖

digraph G {
    rankdir="LR";
    node[shape="point", width=0, height=0];
    edge[arrowhead="none", style="dashed"]

    {
        rank="same";
        edge[style="solided"];
        LC[shape="plaintext"];
        LC -> step00 -> step01 -> step02 -> step03 -> step04 -> step05;
    }

    {
        rank="same";
        edge[style="solided"];
        Agency[shape="plaintext"];
        Agency -> step10 -> step11 -> step12 -> step13 -> step14 -> step15;
    }

    {
        rank="same";
        edge[style="solided"];
        Agent[shape="plaintext"];
        Agent -> step20 -> step21 -> step22 -> step23 -> step24 -> step25;
    }

    step00 -> step10 [label="sends email new custumer", arrowhead="normal"];
    step11 -> step01 [label="declines", arrowhead="normal"];
    step12 -> step02 [label="accepts", arrowhead="normal"];
    step13 -> step23 [label="forward to", arrowhead="normal"];
    step24 -> step14;
    step14 -> step04 [arrowhead="normal"];
}

 

rankdir="LR"表示,佈局從左L到右R。可以看到,在代碼中有{}括起來的部分。

{
    rank="same";
    edge[style="solided"];
    Agency[shape="plaintext"];
    Agency -> step10 -> step11 -> step12 -> step13 -> step14 -> step15;
}

 

每一個rank="same"的block中的所有節點都會在同一條線上。我們設置了所有的線爲虛線,但是在該block中,將線改爲solided

seq

附錄

事實上,從dot的語法及上述的示例中,很容易看出,dot腳本很容易被其他語言生成。比如,使用一些簡單的數據庫查詢就可以生成數據庫中的ER圖的dot腳本。

如果你追求高效的開發速度,並希望快速的將自己的想法畫出來,那麼graphviz是一個很不錯的選擇。

當然,graphviz也有一定的侷限,比如繪製時序圖(序列圖)就很難實現。graphviz的節點出現在畫布上的位置事實上是不確定的,依賴於所使用的佈局算法,而不是在腳本中出現的位置,這可能使剛開始接觸graphviz的開發人員有點不適應。graphviz的強項在於自動佈局,當圖中的頂點和邊的數目變得很多的時候,才能很好的體會這一特性的好處:

complex

比如上圖,或者較上圖更復雜的圖,如果採用手工繪製顯然是不可能的,只能通過graphviz提供的自動佈局引擎來完成。如果僅用於展示模塊間的關係,子模塊與子模塊間通信的方式,模塊的邏輯位置等,graphviz完全可以勝任,但是如果圖中對象的物理位置必須是準確的,如節點A必須位於左上角,節點B必須與A相鄰等特性,使用graphviz則很難做到。畢竟,它的強項是自動佈局,事實上,所有的節點對與佈局引擎而言,權重在初始時都是相同的,只是在渲染之後,節點的大小,形狀等特性纔會影響權重。

本文只是初步介紹了graphviz的簡單應用,如圖的定義,頂點/邊的屬性定義,如果運行等,事實上還有很多的屬性,如畫布的大小,字體的選擇,顏色列表等,大家可以通過graphviz的官網來找到更詳細的資料。

文中的代碼都已經在Github上。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章