用ElasticSearch和Protovis實現數據可視化


搜索引擎最重要的目的,嗯,不出意料就是搜索。你傳給它一個請求,然后它依照相關性返回你一串匹配的結果。我們可以根據自己的內容創造各種請求結構,試驗各種不同的分析器,搜索引擎都會努力嘗試提供最好的結果。

不過,一個現代的全文搜索引擎可以做的比這個更多。因為它的核心是基於一個為了高效查詢匹配文檔而高度優化過的數據結構——倒排索引。它也可以為我們的數據完成復雜的聚合運算,在這里我們叫它facets。(不好翻譯,后文對這個單詞都保留英文)

facets通常的目的是提供給用戶某個方面的導航或者搜索。 當你在網上商店搜索“相機”,你可以選擇不同的制造商,價格范圍或者特定功能來定制條件,這應該就是點一下鏈接的事情,而不是通過修改一長串查詢語法。
Facet搜索為數不多的幾個可以把強大的請求能力開放給最終用戶的辦法之一,詳見Moritz Stefaner的試驗“Elastic Lists”,或許你會有更多靈感。

但是,除了鏈接和復選框,其實我們還能做的更多。比如利用這些數據畫圖,而這就是我們在這篇文章中要講的。

實時儀表板
在幾乎所有的分析、監控和數據挖掘服務中,或早或晚的你都會碰到這樣的需求:“我們要一個儀表板!”。因為大家都愛儀表板,可能因為真的有用,可能單純因為它漂亮~這時候,我們不用寫任何OLAP實現,用facets就可以完成一個很漂亮很給力的分析引擎。

下面的截圖就是從一個社交媒體監控應用上獲取的。這個應用不單用ES來搜索和挖掘數據,還通過交互式儀表板提供數據聚合功能。
當用戶深入數據,添加一個關鍵字,使用一個自定義查詢,所有的圖都會實時更新,這就是facet聚合的工作方式。儀表板上不是數據定期計算好的的靜態快照,而是一個用於數據探索的真正的交互式工具。

在本文中,我們將會學習到怎樣從ES中獲取數據,然后怎么創建這些圖表。

關系聚合(terms facet)的餅圖
第一個圖,我們用ES中比較簡單的termsfacet來做。這個facet會返回一個字段中最常見的詞匯和它的計數值。

首先我們先插入一些數據。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
curl -X DELETE "http://localhost:9200/dashboard"
curl -X POST "http://localhost:9200/dashboard/article" -d '
             { "title" : "One",
               "tags"  : ["ruby", "java", "search"]}
'
curl -X POST "http://localhost:9200/dashboard/article" -d '
             { "title" : "Two",
               "tags"  : ["java", "search"] }
'
curl -X POST "http://localhost:9200/dashboard/article" -d '
             { "title" : "Three",
               "tags"  : ["erlang", "search"] }
'
curl -X POST "http://localhost:9200/dashboard/article" -d '
             { "title" : "Four",
               "tags"  : ["search"] }
'
curl -X POST "http://localhost:9200/dashboard/_refresh"

你們都看到了,我們存儲了一些文章的標簽,每個文章可以多個標簽,數據以JSON格式發送,這也是ES的文檔格式。

現在,要知道文檔的十大標簽,我們只需要簡單的請求:

1
2
3
4
5
6
7
8
curl -X POST "http://localhost:9200/dashboard/_search?pretty=true" -d '
{
    "query" : { "match_all" : {} },
    "facets" : {
        "tags" : { "terms" : {"field" : "tags", "size" : 10} }
    }
}
'

你看到了,我接受所有文檔,然后定義一個terms facet叫做“tags”。這個請求會返回如下樣子的數據:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
    "took" : 2,
    // ... snip ...
    "hits" : {
        "total" : 4,
        // ... snip ...
    },
    "facets" : {
        "tags" : {
            "_type" : "terms",
            "missing" : 1,
            "terms" : [
                { "term" : "search", "count" : 4 },
                { "term" : "java",   "count" : 2 },
                { "term" : "ruby",   "count" : 1 },
                { "term" : "erlang", "count" : 1 }
            ]
        }
    }
}

JSON中facets部分是我們關心的,特別是facets.tags.terms數組。它告訴我們有四篇文章打了search標簽,兩篇java標簽,等等…….(當然,我們或許應該給請求添加一個size參數跳過前面的結果)

這種比例類型的數據最合適的可視化方案就是餅圖,或者它的變體:油炸圈餅圖。
我們將使用Protovis一個JavaScript的數據可視化工具集。Protovis是100%開源的,你可以想象它是數據可視化方面的RoR。和其他類似工具形成鮮明對比的是,它沒有附帶一組圖標類型來供你“選擇”。而是定義了一組原語和一個靈活的DSL,這樣你可以非常簡單的創建自定義的可視化。創建餅圖就非常簡單。

因為ES返回的是JSON數據,我們可以通過Ajax調用加載它。不要忘記你可以clone或者下載實例的全部源代碼。

首先需要一個HTML文件來容納圖標然后從ES里加載數據:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!DOCTYPE html>
<html>
<head>
    <title>ElasticSearch Terms Facet Donut Chart</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <!-- Load JS libraries -->
    <script src="jquery-1.5.1.min.js"></script>
    <script src="protovis-r3.2.js"></script>
    <script src="donut.js"></script>
    <script>
        $( function() { load_data(); });
        var load_data = function() {
            $.ajax({   url: 'http://localhost:9200/dashboard/article/_search?pretty=true'
                     , type: 'POST'
                     , data : JSON.stringify({
                           "query" : { "match_all" : {} },
                           "facets" : {
                               "tags" : {
                                   "terms" : {
                                       "field" : "tags",
                                       "size"  : "10"
                                   }
                               }
                           }
                       })
                     , dataType : 'json'
                     , processData: false
                     , success: function(json, statusText, xhr) {
                           return display_chart(json);
                       }
                     , error: function(xhr, message, error) {
                           console.error("Error while loading data from ElasticSearch", message);
                           throw(error);
                       }
            });
            var display_chart = function(json) {
                Donut().data(json.facets.tags.terms).draw();
            };
        };
    </script>
</head>
<body>
  <!-- Placeholder for the chart -->
  <div id="chart"></div>
</body>
</html>

文檔加載后,我們通過Ajax收到和之前curl測試中一樣的facet。在jQuery的Ajaxcallback里我們通過封裝的display_chart()把返回的JSON傳給Donut()函數.

Donut()函數及注釋如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
var Donut = function(dom_id) {
    if ('undefined' == typeof dom_id)  {                // Set the default DOM element ID to bind
        dom_id = 'chart';
    }
    var data = function(json) {                         // Set the data for the chart
        this.data = json;
        return this;
    };
    var draw = function() {
        var entries = this.data.sort( function(a, b) {  // Sort the data by term names, so the
            return a.term < b.term ? -1 : 1;            // color scheme for wedges is preserved
        }),                                             // with any order
        values  = pv.map(entries, function(e) {         // Create an array holding just the counts
            return e.count;
        });
        // console.log('Drawing', entries, values);
        var w = 200,                                    // Dimensions and color scheme for the chart
            h = 200,
            colors = pv.Colors.category10().range();
        var vis = new pv.Panel()                        // Create the basis panel
            .width(w)
            .height(h)
            .margin(0, 0, 0, 0);
        vis.add(pv.Wedge)                               // Create the "wedges" of the chart
            .def("active", -1)                          // Auxiliary variable to hold mouse over state
            .data( pv.normalize(values) )               // Pass the normalized data to Protovis
            .left(w/3)                                  // Set-up chart position and dimension
            .top(w/3)
            .outerRadius(w/3)
            .innerRadius(15)                            // Create a "donut hole" in the center
            .angle( function(d) {                       // Compute the "width" of the wedge
                return d * 2 * Math.PI;
             })
            .strokeStyle("#fff")                        // Add white stroke
            .event("mouseover", function() {            // On "mouse over", set the "wedge" as active
                this.active(this.index);
                this.cursor('pointer');
                return this.root.render();
             })
            .event("mouseout",  function() {            // On "mouse out", clear the active state
                this.active(-1);
                return this.root.render();
            })
            .event("mousedown", function(d) {           // On "mouse down", perform action,
                var term = entries[this.index].term;    // such as filtering the results...
                return (alert("Filter the results by '"+term+"'"));
            })
            .anchor("right").add(pv.Dot)                // Add the left part of he "inline" label,
                                                        // displayed inside the donut "hole"
            .visible( function() {                      // The label is visible when its wedge is active
                return this.parent.children[0]
                       .active() == this.index;
            })
            .fillStyle("#222")
            .lineWidth(0)
            .radius(14)
            .anchor("center").add(pv.Bar)               // Add the middle part of the label
            .fillStyle("#222")
            .width(function(d) {                        // Compute width:
                return (d*100).toFixed(1)               // add pixels for percents
                              .toString().length*4 +
                       10 +                             // add pixels for glyphs (%, etc)
                       entries[this.index]              // add pixels for letters (very rough)
                           .term.length*9;
            })
            .height(28)
            .top((w/3)-14)
            .anchor("right").add(pv.Dot)                // Add the right part of the label
            .fillStyle("#222")
            .lineWidth(0)
            .radius(14)
            .parent.children[2].anchor("left")          // Add the text to label
                   .add(pv.Label)
            .left((w/3)-7)
            .text(function(d) {                         // Combine the text for label
                return (d*100).toFixed(1) + "%" +
                       ' ' + entries[this.index].term +
                       ' (' + values[this.index] + ')';
            })
            .textStyle("#fff")
            .root.canvas(dom_id)                        // Bind the chart to DOM element
            .render();                                  // And render it.
    };
    return {                                            // Create the public API
        data   : data,
        draw   : draw
    };
};

現在你們看到了,一個簡單的JSON數據轉換,我們就可以創建出豐富的有吸引力的關於我們文章標簽分布的可視化圖標。完整的例子在這里。

當你使用完全不同的請求,比如顯示某個特定作者的文章,或者特定日期內發表的文章,整個可視化都照樣正常工作,代碼是可以重用的。

日期直方圖(date histogram facets)時間線
Protovis讓創建另一種常見的可視化類型也非常容易:時間線。任何類型的數據,只要和特定日期相關的,比如文章發表,事件發生,目標達成,都可以被可視化成時間線。
好了,讓我們往索引里存一些帶有發表日期的文章吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
curl -X DELETE "http://localhost:9200/dashboard"
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "1",  "published" : "2011-01-01" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "2",  "published" : "2011-01-02" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "3",  "published" : "2011-01-02" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "4",  "published" : "2011-01-03" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "5",  "published" : "2011-01-04" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "6",  "published" : "2011-01-04" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "7",  "published" : "2011-01-04" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "8",  "published" : "2011-01-04" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "9",  "published" : "2011-01-10" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "10", "published" : "2011-01-12" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "11", "published" : "2011-01-13" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "12", "published" : "2011-01-14" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "13", "published" : "2011-01-14" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "14", "published" : "2011-01-15" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "15", "published" : "2011-01-20" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "16", "published" : "2011-01-20" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "17", "published" : "2011-01-21" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "18", "published" : "2011-01-22" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "19", "published" : "2011-01-23" }'
curl -X POST "http://localhost:9200/dashboard/article" -d '{ "t" : "20", "published" : "2011-01-24" }'
curl -X POST "http://localhost:9200/dashboard/_refresh"

我們用ES的date histogram facet來獲取文章發表的頻率。

1
2
3
4
5
6
7
8
9
10
11
12
13
curl -X POST "http://localhost:9200/dashboard/_search?pretty=true" -d '
{
    "query" : { "match_all" : {} },
    "facets" : {
        "published_on" : {
            "date_histogram" : {
                "field"    : "published",
                "interval" : "day"
            }
        }
    }
}
'

注意我們是怎么設置間隔為天的。這個很容易就可以替換成周,月 ,或者年。

請求會返回像下面這樣的JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
    "took" : 2,
    // ... snip ...
    "hits" : {
        "total" : 4,
        // ... snip ...
    },
    "facets" : {
        "published" : {
            "_type" : "histogram",
            "entries" : [
                { "time" : 1293840000000, "count" : 1 },
                { "time" : 1293926400000, "count" : 2 }
                // ... snip ...
            ]
        }
    }
}

我們要注意的是facets.published.entries數組,和上面的例子一樣。同樣需要一個HTML頁來容納圖標和加載數據。機制既然一樣,代碼就直接看這里吧。

既然已經有了JSON數據,用protovis創建時間線就很簡單了,用一個自定義的area chart即可。

完整帶注釋的Timeline()函數如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
var Timeline = function(dom_id) {
    if ('undefined' == typeof dom_id) {                 // Set the default DOM element ID to bind
        dom_id = 'chart';
    }
    var data = function(json) {                         // Set the data for the chart
        this.data = json;
        return this;
    };
    var draw = function() {
        var entries = this.data;                        // Set-up the data
            entries.push({                              // Add the last "blank" entry for proper
              count : entries[entries.length-1].count   // timeline ending
            });
        // console.log('Drawing, ', entries);
        var w = 600,                                    // Set-up dimensions and scales for the chart
            h = 100,
            max = pv.max(entries, function(d) {return d.count;}),
            x = pv.Scale.linear(0, entries.length-1).range(0, w),
            y = pv.Scale.linear(0, max).range(0, h);
        var vis = new pv.Panel()                        // Create the basis panel
            .width(w)
            .height(h)
            .bottom(20)
            .left(20)
            .right(40)
            .top(40);
         vis.add(pv.Label)                              // Add the chart legend at top left
            .top(-20)
            .text(function() {
                 var first = new Date(entries[0].time);
                 var last  = new Date(entries[entries.length-2].time);
                 return "Articles published between " +
                     [ first.getDate(),
                       first.getMonth() + 1,
                       first.getFullYear()
                     ].join("/") +
                     " and " +
                     [ last.getDate(),
                       last.getMonth() + 1,
                       last.getFullYear()
                     ].join("/");
             })
            .textStyle("#B1B1B1")
         vis.add(pv.Rule)                               // Add the X-ticks
            .data(entries)
            .visible(function(d) {return d.time;})
            .left(function() { return x(this.index); })
            .bottom(-15)
            .height(15)
            .strokeStyle("#33A3E1")
            .anchor("right").add(pv.Label)              // Add the tick label (DD/MM)
            .text(function(d) {
                 var date = new Date(d.time);
                 return [
                     date.getDate(),
                     date.getMonth() + 1
                 ].join('/');
             })
            .textStyle("#2C90C8")
            .textMargin("5")
         vis.add(pv.Rule)                               // Add the Y-ticks
            .data(y.ticks(max))                         // Compute tick levels based on the "max" value
            .bottom(y)
            .strokeStyle("#eee")
            .anchor("left").add(pv.Label)
                .text(y.tickFormat)
                .textStyle("#c0c0c0")
        vis.add(pv.Panel)                               // Add container panel for the chart
           .add(pv.Area)                                // Add the area segments for each entry
           .def("active", -1)                           // Auxiliary variable to hold mouse state
           .data(entries)                               // Pass the data to Protovis
           .bottom(0)
           .left(function(d) {return x(this.index);})   // Compute x-axis based on scale
           .height(function(d) {return y(d.count);})    // Compute y-axis based on scale
           .interpolate('cardinal')                     // Make the chart curve smooth
           .segmented(true)                             // Divide into "segments" (for interactivity)
           .fillStyle("#79D0F3")
           .event("mouseover", function() {             // On "mouse over", set segment as active
               this.active(this.index);
               return this.root.render();
           })
           .event("mouseout",  function() {             // On "mouse out", clear the active state
               this.active(-1);
               return this.root.render();
           })
           .event("mousedown", function(d) {            // On "mouse down", perform action,
               var time = entries[this.index].time;     // eg filtering the results...
               return (alert("Timestamp: '"+time+"'"));
           })
           .anchor("top").add(pv.Line)                  // Add thick stroke to the chart
           .lineWidth(3)
           .strokeStyle('#33A3E1')
           .anchor("top").add(pv.Dot)                   // Add the circle "label" displaying
                                                        // the count for this day
           .visible( function() {                       // The label is only visible when
               return this.parent.children[0]           // its segment is active
                          .active() == this.index;
            })
           .left(function(d) { return x(this.index); })
           .bottom(function(d) { return y(d.count); })
           .fillStyle("#33A3E1")
           .lineWidth(0)
           .radius(14)
           .anchor("center").add(pv.Label)             // Add text to the label
           .text(function(d) {return d.count;})
           .textStyle("#E7EFF4")
           .root.canvas(dom_id)                        // Bind the chart to DOM element
           .render();                                  // And render it.
    };
    return {                                            // Create the public API
        data   : data,
        draw   : draw
    };
};

完整示例代碼在這里。不過先去下載protovis提供的關於area的原始文檔,然后觀察當你修改interpolate(‘cardinal’)成interpolate(‘step-after’)后發生了什么。對於多個facet,畫疊加的區域圖,添加交互性,然后完全定制可視化應該都不是什么問題了。

重要的是注意,這個圖表完全是根據你傳遞給ES的請求做出的響應,使得你有可能做到簡單立刻的完成某項指標的可視化需求。比如“顯示這個作者在這個主題上最近三個月的出版頻率”。只需要提交這樣的請求就夠了:

1
author:John AND topic:Search AND published:[2011-03-01 TO 2011-05-31]

總結
當你需要為復雜的自定義查詢做一個豐富的交互式的數據可視化時,使用ES的facets應該是最容易的辦法之一,你只需要傳遞ES的JSON響應給Protovis這樣的工具就好了。

通過模仿本文中的方法和代碼,你可以在幾小時內給你的數據跑通一個示例。

REFERENECE : http://www.chepoo.com/elasticsearch-protovis.html | IT技術精華網 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM