Storm實時計算:流操作入門編程實踐

Storm是一個分佈式是實時計算系統,它設計了一種對流和計算的抽象,概念比較簡單,實際編程開發起來相對容易。下面,簡單介紹編程實踐過程中需要理解的Storm中的幾個概念:

  • Topology

Storm中Topology的概念類似於Hadoop中的MapReduce Job,是一個用來編排、容納一組計算邏輯組件(Spout、Bolt)的對象(Hadoop MapReduce中一個Job包含一組Map Task、Reduce Task),這一組計算組件可以按照DAG圖的方式編排起來(通過選擇Stream Groupings來控制數據流分發流向),從而組合成一個計算邏輯更加負責的對象,那就是Topology。一個Topology運行以後就不能停止,它會無限地運行下去,除非手動干預(顯式執行bin/storm kill )或意外故障(如停機、整個Storm集羣掛掉)讓它終止。

  • Spout

Storm中Spout是一個Topology的消息生產的源頭,Spout應該是一個持續不斷生產消息的組件,例如,它可以是一個Socket Server在監聽外部Client連接併發送消息,可以是一個消息隊列(MQ)的消費者、可以是用來接收Flume Agent的Sink所發送消息的服務,等等。Spout生產的消息在Storm中被抽象爲Tuple,在整個Topology的多個計算組件之間都是根據需要抽象構建的Tuple消息來進行連接,從而形成流。

  • Bolt

Storm中消息的處理邏輯被封裝到Bolt組件中,任何處理邏輯都可以在Bolt裏面執行,處理過程和普通計算應用程序沒什麼區別,只是需要根據Storm的計算語義來合理設置一下組件之間消息流的聲明、分發、連接即可。Bolt可以接收來自一個或多個Spout的Tuple消息,也可以來自多個其它Bolt的Tuple消息,也可能是Spout和其它Bolt組合發送的Tuple消息。

  • Stream Grouping

Storm中用來定義各個計算組件(Spout、Bolt)之間流的連接、分組、分發關係。Storm定義瞭如下7種分發策略:Shuffle Grouping(隨機分組)、Fields Grouping(按字段分組)、All Grouping(廣播分組)、Global Grouping(全局分組)、Non Grouping(不分組)、Direct Grouping(直接分組)、Local or Shuffle Grouping(本地/隨機分組),各種策略的具體含義可以參考Storm官方文檔、比較容易理解。

下面,作爲入門實踐,我們簡單介紹幾種開發中常用的流操作處理方式的實現:

Storm組件簡單串行

這種方式是最簡單最直觀的,只要我們將Storm的組件(Spout、Bolt)串行起來即可實現,只需要瞭解編寫這些組件的基本方法即可。在實際應用中,如果我們需要從某一個數據源連續地接收消息,然後順序地處理每一個請求,就可以使用這種串行方式來處理。如果說處理單元的邏輯非常複雜,那麼就需要處理邏輯進行分離,屬於同一類操作的邏輯封裝到一個處理組件中,做到各個組件之間弱耦合(除了定義Field的schema外,只通過發送消息來連接各個組件)。
下面,我實現一個簡單的WordCount的例子,各個組件之間的連接方式,如下圖所示:
wordcount-topology
ProduceRecordSpout類是一個Spout組件,用來產生消息,我們這裏模擬發送一些英文句子,實際應用中可以指定任何數據源,如數據庫、消息中間件、Socket連接、RPC調用等等。ProduceRecordSpout類代碼如下所示:

01 public static class ProduceRecordSpout extends BaseRichSpout {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(ProduceRecordSpout.class);
05      private SpoutOutputCollector collector;
06      private Random random;
07      private String[] records;
08      
09      public ProduceRecordSpout(String[] records) {
10           this.records = records;
11      }
12      
13      @Override
14      public void open(Map conf, TopologyContext context, SpoutOutputCollector collector) {
15           this.collector = collector;   
16           random = new Random();
17      }
18  
19      @Override
20      public void nextTuple() {
21           Utils.sleep(500);
22           String record = records[random.nextInt(records.length)];
23           List<Object> values = new Values(record);
24           collector.emit(values, values);
25           LOG.info("Record emitted: record=" + record);
26      }
27  
28      @Override
29      public void declareOutputFields(OutputFieldsDeclarer declarer) {
30           declarer.declare(new Fields("record"));        
31      }
32 }

構造一個ProduceRecordSpout對象時,傳入一個字符串數組,然後隨機地選擇其中一個句子,emit到下游(Downstream)的WordSplitterBolt組件,只聲明瞭一個Field,WordSplitterBolt組件可以根據聲明的Field,接收到emit的消息,WordSplitterBolt類代碼實現如下所示:

01 public static class WordSplitterBolt extends BaseRichBolt {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(WordSplitterBolt.class);
05      private OutputCollector collector;
06      
07      @Override
08      public void prepare(Map stormConf, TopologyContext context,
09                OutputCollector collector) {
10           this.collector = collector;             
11      }
12  
13      @Override
14      public void execute(Tuple input) {
15           String record = input.getString(0);
16           if(record != null && !record.trim().isEmpty()) {
17                for(String word : record.split("\\s+")) {
18                     collector.emit(input, new Values(word, 1));
19                     LOG.info("Emitted: word=" + word);
20                     collector.ack(input);
21                }
22           }
23      }
24  
25      @Override
26      public void declareOutputFields(OutputFieldsDeclarer declarer) {
27           declarer.declare(new Fields("word""count"));        
28      }
29      
30 }

在execute方法中,傳入的參數是一個Tuple,該Tuple就包含了上游(Upstream)組件ProduceRecordSpout所emit的數據,直接取出數據進行處理。上面代碼中,我們將取出的數據,按照空格進行的split,得到一個一個的單詞,然後在emit到下一個組件,聲明的輸出schema爲2個Field:word和count,當然這裏面count的值都爲1。
進行統計詞頻的組件爲WordCounterBolt,實現代碼如下所示:

01 public static class WordCounterBolt extends BaseRichBolt {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(WordCounterBolt.class);
05      private OutputCollector collector;
06      private final Map<String, AtomicInteger> counterMap = Maps.newHashMap();
07      
08      @Override
09      public void prepare(Map stormConf, TopologyContext context,
10                OutputCollector collector) {
11           this.collector = collector;             
12      }
13  
14      @Override
15      public void execute(Tuple input) {
16           String word = input.getString(0);
17           int count = input.getIntegerByField("count"); // 通過Field名稱取出對應字段的數據
18           AtomicInteger ai = counterMap.get(word);
19           if(ai == null) {
20                ai = new AtomicInteger(0);
21                counterMap.put(word, ai);
22           }
23           ai.addAndGet(count);
24           LOG.info("DEBUG: word=" + word + ", count=" + ai.get());
25           collector.ack(input);
26      }
27  
28      @Override
29      public void declareOutputFields(OutputFieldsDeclarer declarer) {          
30      }
31      
32      @Override
33      public void cleanup() {
34           // print count results
35           LOG.info("Word count results:");
36           for(Entry<String, AtomicInteger> entry : counterMap.entrySet()) {
37                LOG.info("\tword=" + entry.getKey() + ", count=" + entry.getValue().get());
38           }
39      }
40  
41 }

上面代碼通過一個Map來對每個單詞出現的頻率進行累加計數,比較簡單。因爲該組件是Topology的最後一個組件,所以不需要在declareOutputFields方法中聲明Field的Schema,而是在cleanup方法中輸出最終的結果,只有在該組件結束任務退出時纔會調用cleanup方法輸出。
最後,需要基於上面的3個組件來創建一個Topology實例,提交到Storm集羣去運行,配置代碼如下所示:

01 public static void main(String[] args) throws AlreadyAliveException, InvalidTopologyException, InterruptedException {
02      // configure & build topology
03      TopologyBuilder builder = new TopologyBuilder();
04      String[] records = new String[] {
05                "A Storm cluster is superficially similar to a Hadoop cluster",
06                "All coordination between Nimbus and the Supervisors is done through a Zookeeper cluster",
07                "The core abstraction in Storm is the stream"
08      };
09      builder
10           .setSpout("spout-producer"new ProduceRecordSpout(records), 1)
11           .setNumTasks(3);
12      builder
13           .setBolt("bolt-splitter"new WordSplitterBolt(), 2)
14           .shuffleGrouping("spout-producer")
15           .setNumTasks(2);
16      builder.setBolt("bolt-counter"new WordCounterBolt(), 1)
17           .fieldsGrouping("bolt-splitter"new Fields("word"))
18           .setNumTasks(2);
19      
20      // submit topology
21      Config conf = new Config();
22      String name = WordCountTopology.class.getSimpleName();
23      if (args != null && args.length > 0) {
24           String nimbus = args[0];
25           conf.put(Config.NIMBUS_HOST, nimbus);
26           conf.setNumWorkers(2);
27           StormSubmitter.submitTopologyWithProgressBar(name, conf, builder.createTopology());
28      else {
29           LocalCluster cluster = new LocalCluster();
30           cluster.submitTopology(name, conf, builder.createTopology());
31           Thread.sleep(60000);
32           cluster.shutdown();
33      }
34 }

上面通過TopologyBuilder來配置組成一個Topology的多個組件(Spout或Bolt),然後通過調用createTopology()方法創建一個Topology實例。上面方法中,對應着2種運行模式:如果沒有傳遞任何參數,則是使用LocalCluster來運行,適合本地調試代碼;如果傳遞一個Topology名稱作爲參數,則是在真實的Storm集羣上運行,需要對實現的Topology代碼進行編譯打包,通過StormSubmitter提交到集羣上作爲服務運行。

Storm組合多種流操作

Storm支持流聚合操作,將多個組件emit的數據,匯聚到同一個處理組件來統一處理,可以實現對多個Spout組件通過流聚合到一個Bolt組件(Sout到Bolt的多對一、多對多操作),也可以實現對多個Bolt通過流聚合到另一個Bolt組件(Bolt到Bolt的多對一、多對多操作)。實際,這裏面有兩種主要的操作,一種是類似工作流中的fork,另一種是類似工作流中的join。下面,我們實現一個例子來演示如何使用,實時流處理邏輯如下圖所示:
multiple-streams-topology
上圖所描述的實時流處理流程,我們期望能夠按照如下流程進行處理:

  • 存在3類數據:數字字符串(NUM)、字母字符串(STR)、特殊符號字符串(SIG)
  • 每個ProduceRecordSpout負責處理上面提到的3類數據
  • 所有數據都是字符串,字符串中含有空格,3種類型的ProduceRecordSpout所emit的數據都需要被相同的邏輯處理:根據空格來拆分字符串
  • 一個用來分發單詞的組件DistributeWordByTypeBolt能夠接收到所有的單詞(包含類型信息),統一將每類單詞分別分發到指定的一個用來存儲數據的組件
  • SaveDataBolt用來存儲處理過的單詞,對於不同類型單詞具有不同的存儲邏輯,需要設置3類SaveDataBolt

將Spout分爲3類,每一個Spout發射不同類型的字符串,這裏定義了一個Type常量類來區分這三種類型:

1 interface Type {
2      String NUMBER = "NUMBER";
3      String STRING = "STRING";
4      String SIGN = "SIGN";
5 }

首先看一下,我們實現的Topology是如何進行創建的,代碼如下所示:

01 public static void main(String[] args) throws AlreadyAliveException, InvalidTopologyException, InterruptedException {
02  
03      // configure & build topology
04      TopologyBuilder builder = new TopologyBuilder();
05      
06      // configure 3 spouts
07      builder.setSpout("spout-number"new ProduceRecordSpout(Type.NUMBER, new String[] {"111 222 333""80966 31"}), 1);
08      builder.setSpout("spout-string"new ProduceRecordSpout(Type.STRING, new String[] {"abc ddd fasko""hello the word"}),1);
09      builder.setSpout("spout-sign"new ProduceRecordSpout(Type.SIGN, new String[] {"++ -*% *** @@""{+-} ^#######"}), 1);
10      
11      // configure splitter bolt
12      builder.setBolt("bolt-splitter"new SplitRecordBolt(), 2)
13           .shuffleGrouping("spout-number")
14           .shuffleGrouping("spout-string")
15           .shuffleGrouping("spout-sign");
16      
17      // configure distributor bolt
18      builder.setBolt("bolt-distributor"new DistributeWordByTypeBolt(), 6)
19           .fieldsGrouping("bolt-splitter"new Fields("type"));
20      
21      // configure 3 saver bolts
22      builder.setBolt("bolt-number-saver"new SaveDataBolt(Type.NUMBER), 3)
23           .shuffleGrouping("bolt-distributor""stream-number-saver");
24      builder.setBolt("bolt-string-saver"new SaveDataBolt(Type.STRING), 3)
25           .shuffleGrouping("bolt-distributor""stream-string-saver");
26      builder.setBolt("bolt-sign-saver"new SaveDataBolt(Type.SIGN), 3)
27           .shuffleGrouping("bolt-distributor""stream-sign-saver");
28      
29      // submit topology
30      Config conf = new Config();
31      String name = MultiStreamsWordDistributionTopology.class.getSimpleName();
32      if (args != null && args.length > 0) {
33           String nimbus = args[0];
34           conf.put(Config.NIMBUS_HOST, nimbus);
35           conf.setNumWorkers(3);
36           StormSubmitter.submitTopologyWithProgressBar(name, conf, builder.createTopology());
37      else {
38           LocalCluster cluster = new LocalCluster();
39           cluster.submitTopology(name, conf, builder.createTopology());
40           Thread.sleep(60 60 1000);
41           cluster.shutdown();
42      }
43 }

一個SplitRecordBolt組件從3個不同類型的ProduceRecordSpout接收數據,這是一個多Spout流聚合。SplitRecordBolt將處理後的數據發送給DistributeWordByTypeBolt組件,然後根據收到的數據的類型進行一個分發處理,這裏用了fieldsGrouping操作,也就是SplitRecordBolt發送的數據會按照類型發送到不同的DistributeWordByTypeBolt任務(Task),每個Task收到的一定是同一個類型的數據,如果直接使用shuffleGrouping操作也沒有問題,只不過每個Task可能收到任何類型的數據,在DistributeWordByTypeBolt內部進行流向控制。DistributeWordByTypeBolt組件中定義了多個stream,根據類型來分組發送給不同類型的SaveDataBolt組件。
下面看每個組件的實現:

  • ProduceRecordSpout組件

通過我們定義的一個ProduceRecordSpout類,可以創建3個不同的ProduceRecordSpout實例,每個實例負責生產特定類型的數據,實現代碼如下所示:

01 public static class ProduceRecordSpout extends BaseRichSpout {
02  
03           private static final long serialVersionUID = 1L;
04           private static final Log LOG = LogFactory.getLog(ProduceRecordSpout.class);
05           private SpoutOutputCollector collector;
06           private Random rand;
07           private String[] recordLines;
08           private String type;
09           
10           public ProduceRecordSpout(String type, String[] lines) {
11                this.type = type;
12                recordLines = lines;
13           }
14           
15           @Override
16           public void open(Map conf, TopologyContext context, SpoutOutputCollector collector) {
17                this.collector = collector;   
18                rand = new Random();
19           }
20  
21  
22           @Override
23           public void nextTuple() {
24                Utils.sleep(500);
25                String record = recordLines[rand.nextInt(recordLines.length)];
26                List<Object> values = new Values(type, record);
27                collector.emit(values, values);
28                LOG.info("Record emitted: type=" + type + ", record=" + record);
29           }
30  
31           @Override
32           public void declareOutputFields(OutputFieldsDeclarer declarer) {
33                declarer.declare(new Fields("type""record"));        
34           }
35      }

這比較簡單,根據傳遞的參數來創建上圖中的3個Spout實例。

  • SplitRecordBolt組件

由於前面3個ProduceRecordSpout產生的數據,在開始時的處理邏輯是相同的,所以可以將3個ProduceRecordSpout聚合到一個包含通用邏輯的SplitRecordBolt組件,實現如下所示:

01 public static class SplitRecordBolt extends BaseRichBolt {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(SplitRecordBolt.class);
05      private OutputCollector collector;
06      
07      @Override
08      public void prepare(Map stormConf, TopologyContext context,
09                OutputCollector collector) {
10           this.collector = collector;   
11      }
12  
13      @Override
14      public void execute(Tuple input) {
15           String type = input.getString(0);
16           String line = input.getString(1);
17           if(line != null && !line.trim().isEmpty()) {
18                for(String word  : line.split("\\s+")) {
19                     collector.emit(input, new Values(type, word));
20                     LOG.info("Word emitted: type=" + type + ", word=" + word);
21                     // ack tuple
22                     collector.ack(input);
23                }
24           }
25      }
26  
27      @Override
28      public void declareOutputFields(OutputFieldsDeclarer declarer) {
29           declarer.declare(new Fields("type""word"));
30      }
31 }

無論接收到的Tuple是什麼類型(STRING、NUMBER、SIGN)的數據,都進行split,然後在emit的時候,仍然將類型信息傳遞給下一個Bolt組件。

  • DistributeWordByTypeBolt組件

DistributeWordByTypeBolt組件只是用來分發Tuple,通過定義Stream,將接收到的Tuple發送到指定的下游Bolt組件進行處理。通過SplitRecordBolt組件emit的Tuple包含了類型信息,所以在DistributeWordByTypeBolt中根據類型來進行分發,代碼實現如下:

01 public static class DistributeWordByTypeBolt extends BaseRichBolt {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(DistributeWordByTypeBolt.class);
05      private OutputCollector collector;
06      
07      @Override
08      public void prepare(Map stormConf, TopologyContext context,
09                OutputCollector collector) {
10           this.collector = collector;   
11           Map<GlobalStreamId, Grouping> sources = context.getThisSources();
12           LOG.info("sources==> " + sources);
13      }
14  
15      @Override
16      public void execute(Tuple input) {
17           String type = input.getString(0);
18           String word = input.getString(1);
19           switch(type) {
20                case Type.NUMBER:
21                     emit("stream-number-saver", type, input, word);
22                     break;
23                case Type.STRING:
24                     emit("stream-string-saver", type, input, word);
25                     break;
26                case Type.SIGN:
27                     emit("stream-sign-saver", type, input, word);
28                     break;
29                default:
30                     // if unknown type, record is discarded.
31                     // as needed, you can define a bolt to subscribe the stream 'stream-discarder'.
32                     emit("stream-discarder", type, input, word);
33           }
34           // ack tuple
35           collector.ack(input);
36      }
37      
38      private void emit(String streamId, String type, Tuple input, String word) {
39           collector.emit(streamId, input, new Values(type, word));
40           LOG.info("Distribution, typed word emitted: type=" + type + ", word=" + word);
41      }
42  
43      @Override
44      public void declareOutputFields(OutputFieldsDeclarer declarer) {
45           declarer.declareStream("stream-number-saver"new Fields("type""word"));
46           declarer.declareStream("stream-string-saver"new Fields("type""word"));
47           declarer.declareStream("stream-sign-saver"new Fields("type""word"));
48           declarer.declareStream("stream-discarder"new Fields("type""word"));
49      }
50 }

實際上,下游的3個Bolt組件(SaveDataBolt)在訂閱該流組件(DistributeWordByTypeBolt)的時候,方式相同,只是分發的邏輯交由DistributeWordByTypeBolt來統一控制。
我們在配置該Bolt組件時,使用了fieldsGrouping分組方式,實際每個DistributeWordByTypeBolt只會收到同一種類型的Tuple,這裏也可以使用shuffleGrouping分組方式,這種分組方式會有不同類型的Tuple被emit到同一個DistributeWordByTypeBolt組件上。
另外,該Bolt組件中我們還定義了一個名稱爲stream-discarder的stream,在Topology中並沒有滿足該stream的條件,可以根據實際情況選擇是否實現它。

  • SaveDataBolt組件

最後這個Bolt用來模擬保存處理過的數據內容,代碼如下:

01 public static class SaveDataBolt extends BaseRichBolt {
02  
03      private static final long serialVersionUID = 1L;
04      private static final Log LOG = LogFactory.getLog(SaveDataBolt.class);
05      private OutputCollector collector;
06      
07      private String type;
08      
09      public SaveDataBolt(String type) {
10           this.type = type;
11      }
12      
13      @Override
14      public void prepare(Map stormConf, TopologyContext context,
15                OutputCollector collector) {
16           this.collector = collector;   
17      }
18  
19      @Override
20      public void execute(Tuple input) {
21           // just print the received tuple for being waited to persist
22           LOG.info("[" + type + "] " +
23                     "SourceComponent=" + input.getSourceComponent() +
24                     ", SourceStreamId=" + input.getSourceStreamId() +
25                     ", type=" + input.getString(0) +
26                     ", value=" + input.getString(1));
27      }
28  
29      @Override
30      public void declareOutputFields(OutputFieldsDeclarer declarer) {
31           // do nothing        
32      }
33      
34 }

在實際應用中,你可能需要將處理過的數據保存到數據庫中,就可以在該Bolt中實現存儲數據的邏輯。

總結

Storm中最核心的計算組件的抽象就是Spout、Bolt,以及Stream Grouping,其它高級的功能,像Trident、DRPC,他們或者基於這些基礎組件以及Streaming Grouping分發策略來實現的,屏蔽了底層的分發計算處理邏輯以更高層的編程抽象面向開發者,減輕了開發人員對底層複雜機制的處理;或者是爲了方便使用Storm計算服務而增加的計算機制衍生物,如批量事務處理、RPC等。

參考鏈接

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