├── README.md ├── chapter1 ├── Basics.jpeg ├── Basics.md ├── Figure 1-1 A simple topology.jpeg ├── Figure 1-2. Components of a Storm cluster.png ├── The Components of Storm.md └── The Properties of Storm.md ├── chapter2 ├── Figure 2-1. Getting started topology.png ├── Getting Started.md ├── Hello World Storm.md └── Operation Modes.md ├── chapter3 ├── Figure3-1 DRPC Topology schema.png └── 拓扑.md └── index.md /README.md: -------------------------------------------------------------------------------- 1 | GettingStartedWithStorm-cn 2 | ========================== 3 | 这个项目不更新了,所有译文已经完成并贴到了www.ifeve.com上面。有兴趣的朋友请到本网站的页头菜单寻找 4 | 5 | 翻译Getting Started with Storm 6 | 所有Storm相关术语都用斜体英文表示。 7 | 这些术语的字面意义翻译如下,由于这个工具的名字叫Storm,这些术语一律按照气象名词解释 8 | 9 | Storm 暴风雨 10 | 11 | *spout* 龙卷,读取原始数据为*bolt*提供数据 12 | 13 | *bolt* 雷电,从*spout*或其它*bolt*接收数据,并处理数据,处理结果可作为其它*bolt*的数据源或最终结果 14 | 15 | *nimbus* 雨云,主节点的守护进程,负责为工作节点分发任务。 16 | 17 | 下面的术语跟气象就没有关系了 18 | 19 | *topology* 拓扑结构,Storm的一个任务单元 20 | 21 | *define field(s)* 定义域,由*spout*或*bolt*提供,被*bolt*接收 22 | 23 | *tuple* 元组,具名列表,任意可被序列化的java对象。Storm默认可序列化字符串、字节数组、ArrayList、HashMap、HashSet 24 | -------------------------------------------------------------------------------- /chapter1/Basics.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runfriends/GettingStartedWithStorm-cn/dfcfb7c5bb8f8a8ea20baa1e56f4ce1a47bd0b5b/chapter1/Basics.jpeg -------------------------------------------------------------------------------- /chapter1/Basics.md: -------------------------------------------------------------------------------- 1 | **基础知识** 2 | 3 | Storm是一个分布式的,可靠的,容错的数据流处理系统。它会把工作任务委托给不同类型的组件,每个组件负责处理一项简单特定的任务。Storm集群的输入流由一个被称作*spout*的组件管理,*spout*把数据传递给*bolt*, *bolt*要么把数据保存到某种存储器,要么把数据传递给其它的*bolt*。你可以想象一下,一个Storm集群就是在一连串的*bolt*之间转换*spout*传过来的数据。 4 | 5 | 这里用一个简单的例子来说明这个概念。昨晚我在新闻节目里看到主持人在谈论政治人物和他们对于各种政治话题的立场。他们一直重复着不同的名字,而我开始考虑这些名字是否被提到了相同的次数,以及不同次数之间的偏差。 6 | 7 | 想像播音员读的字幕作为你的数据输入流。你可以用一个*spout*读取一个文件(或者socket,通过HTTP,或者别的方法)。文本行被*spout*传给一个*bolt*,再被bolt按单词切割。单词流又被传给另一个*bolt*,在这里每个单词与一张政治人名列表比较。每遇到一个匹配的名字,第二个*bolt*为这个名字在数据库的计数加1。你可以随时查询数据库查看结果, 而且这些计数是随着数据到达实时更新的。所有组件(*spouts*和*bolts*)及它们之间的关系请参考拓扑图1-1 8 | ![Figure 1-1 A simple topology][1] 9 | 10 | 现在想象一下,很容易在整个Storm集群定义每个*bolt* 和*spout*的并行性级别,因此你可以无限的扩展你的拓扑结构。很神奇,是吗?尽管这是个简单例子,你也可以看到Storm的强大。 11 | 12 | 有哪些典型的Storm应用案例? 13 | 14 | 数据处理流 15 | 正如上例所展示的,不像其它的流处理系统,Storm不需要中间队列。 16 | 17 | 连续计算 18 | 连续发送数据到客户端,使它们能够实时更新并显示结果,如网站指标。 19 | 20 | 分布式远程过程调用 21 | 22 | 频繁的CPU密集型操作并行化。 23 | 24 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter1/Figure%201-1%20A%20simple%20topology.jpeg 25 | -------------------------------------------------------------------------------- /chapter1/Figure 1-1 A simple topology.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runfriends/GettingStartedWithStorm-cn/dfcfb7c5bb8f8a8ea20baa1e56f4ce1a47bd0b5b/chapter1/Figure 1-1 A simple topology.jpeg -------------------------------------------------------------------------------- /chapter1/Figure 1-2. Components of a Storm cluster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runfriends/GettingStartedWithStorm-cn/dfcfb7c5bb8f8a8ea20baa1e56f4ce1a47bd0b5b/chapter1/Figure 1-2. Components of a Storm cluster.png -------------------------------------------------------------------------------- /chapter1/The Components of Storm.md: -------------------------------------------------------------------------------- 1 | **Storm组件** 2 | 3 | 对于一个Storm集群,一个连续运行的主节点组织若干结点工作。 4 | 5 | 在Storm集群中,有两类节点:主节点*master node*和工作节点*worker nodes*。主节点运行着一个叫做*Nimbus*的守护进程。这个守护进程负责在集群中分发代码,为工作节点分配任务,并监控故障。Supervisor守护进程作为拓扑的一部分运行在工作节点上。一个Storm拓扑结构在不同的机器上运行着众多的工作节点。 6 | 7 | 因为Storm在Zookeeper或本地磁盘上维持所有的集群状态,守护进程可以是无状态的而且失效或重启时不会影响整个系统的健康(见图1-2) 8 | ![图1-2 Storm集群的组件][1] 9 | 10 | 在系统底层,Storm使用了zeromq(0mq, zeromq([http://www.zeromq.org][2]))。这是一种先进的,可嵌入的网络通讯库,它提供的绝妙功能使Storm成为可能。下面列出一些zeromq的特性。 11 | 12 | - 一个并发架构的Socket库 13 | - 对于集群产品和超级计算,比TCP要快 14 | - 可通过inproc(进程内), IPC(进程间), TCP和multicast(多播协议)通信 15 | - 异步I / O的可扩展的多核消息传递应用程序 16 | - 利用扇出(fanout), 发布订阅(PUB-SUB),管道(pipeline), 请求应答(REQ-REP),等方式实现N-N连接 17 | 18 | **NOTE**: Storm只用了push/pull sockets 19 | 20 | 21 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter1/Figure%201-2.%20Components%20of%20a%20Storm%20cluster.png 22 | 23 | [2]: http://www.zeromq.org 24 | -------------------------------------------------------------------------------- /chapter1/The Properties of Storm.md: -------------------------------------------------------------------------------- 1 | **Storm的特性** 2 | 3 | 在所有这些设计思想与决策中,有一些非常棒的特性成就了独一无二的Storm。 4 | 5 | - 简化编程 6 | 7 | 如果你曾试着从零开始实现实时处理,你应该明白这是一件多么痛苦的事情。Storm使复杂性被大大降低了。 8 | 9 | - 支持多语言编程 10 | 11 | 使用一门基于JVM的语言开发会更容易,但是你可以借助一个小的中间件,在Storm上使用任何语言开发。有现成的中间件可供选择,当然也可以自己开发中间件。 12 | 13 | - 容错 14 | 15 | Storm集群会关注工作节点状态,如果宕机了必要的时候会重新分配任务。 16 | 17 | - 可扩展 18 | 19 | 所有你需要为扩展集群所做的工作就是增加机器。Storm会在新机器就绪时向它们分配任务。 20 | 21 | - 可靠的 22 | 23 | 所有消息都可保证至少处理一次。如果出错了,消息可能处理不只一次,不过你永远不会丢失消息。 24 | 25 | - 快速 26 | 27 | 速度是驱动Storm设计的一个关键因素。 28 | 29 | - 事务性 30 | 31 | 你可以为几乎任何计算得到恰好一次消息语义。 32 | -------------------------------------------------------------------------------- /chapter2/Figure 2-1. Getting started topology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runfriends/GettingStartedWithStorm-cn/dfcfb7c5bb8f8a8ea20baa1e56f4ce1a47bd0b5b/chapter2/Figure 2-1. Getting started topology.png -------------------------------------------------------------------------------- /chapter2/Getting Started.md: -------------------------------------------------------------------------------- 1 | **准备开始** 2 | 3 | 在本章,我们要创建一个Storm工程和我们的第一个Storm拓扑结构。 4 | 5 | **NOTE**: 下面假设你的JRE版本在1.6以上。我们推荐Oracle提供的JRE。你可以到[http://www.java 6 | .com/downloads/][1]下载 7 | 8 | 9 | [1]: http://www.java%20.com/downloads/ 10 | -------------------------------------------------------------------------------- /chapter2/Hello World Storm.md: -------------------------------------------------------------------------------- 1 | ###**Hello World** 2 | 3 | 我们在这个工程里创建一个简单的拓扑,数单词数量。我们可以把这个看作Storm的“Hello World”。不过,这是一个非常强大的拓扑,因为它能够扩展到几乎无限大的规模,而且只需要做一些小修改,就能用它构建一个统计系统。举个例子,我们可以修改一下工程用来找出Twitter上的热点话题。 4 | 5 | 要创建这个拓扑,我们要用一个*spout*读取文本,第一个*bolt*用来标准化单词,第二个*bolt*为单词计数,如图2-1所示。 6 | 7 | ![图2-1 拓扑入门][1] 8 | 9 | 你可以从这个网址下载源码压缩包,[ https://github.com/ 10 | storm-book/examples-ch02-getting_started/zipball/master][2]。 11 | 12 | **NOTE**: 如果你使用[git][3](一个分布式版本控制与源码管理工具),你可以执行git clone git@github.com:storm-book/examples-ch02-getting_started.git,把源码检出到你指定的目录。 13 | 14 | ###**Java安装检查** 15 | 16 | 构建Storm运行环境的第一步是检查你安装的Java版本。打开一个控制台窗口并执行命令:java -version。控制台应该会显示出类似如下的内容: 17 | ```sh 18 | java -version 19 | 20 | java version "1.6.0_26" 21 | Java(TM) SE Runtime Enviroment (build 1.6.0_26-b03) 22 | 23 | Java HotSpot(TM) Server VM (build 20.1-b02, mixed mode) 24 | ``` 25 | 如果不是上述内容,检查你的Java安装情况。(参考[http://www.java.com/download/][4] 26 | 27 | ###**创建工程** 28 | 29 | 开始之前,先为这个应用建一个目录(就像你平常为Java应用做的那样)。这个目录用来存放工程源码。 30 | 31 | 接下来我们要下载Storm依赖包,这是一些jar包,我们要把它们添加到应用类路径中。你可以采用如下两种方式之一完成这一步: 32 | 33 | - 下载所有依赖,解压缩它们,把它 们添加到类路径 34 | - 使用*[Apache Maven][5]* 35 | 36 | **NOTE**: Maven是一个软件项目管理的综合工具。它可以用来管理项目的开发周期的许多方面,从包依赖到版本发布过程。在这本书中,我们将广泛使用它。如果要检查是否已经安装了maven,在命令行运行mvn。如果没有安装你可以从[http://maven.apache.org/download.html][6]下载。 37 | 38 | 没有必要先成为一个Maven专家才能使用Storm,不过了解一下关于Maven工作方式的基础知识仍然会对你有所帮助。你可以在Apache Maven的网站上找到更多的信息([http://maven.apache.org/][7])。 39 | 40 | **NOTE:** Storm的Maven依赖引用了运行Storm本地模式的所有库。 41 | 42 | 要运行我们的拓扑,我们可以编写一个包含基本组件的pom.xml文件。 43 | ```xml 44 | 48 | 4.0.0 49 | storm.book 50 | Getting-Started 51 | 0.0.1-SNAPSHOT 52 | 53 | 54 | 55 | org.apache.maven.plugins 56 | maven-compiler-plugin 57 | 2.3.2 58 | 59 | 1.6 60 | 1.6 61 | 1.6 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | clojars.org 70 | http://clojars.org/repo 71 | 72 | 73 | 74 | 75 | 76 | storm 77 | storm 78 | 0.6.0 79 | 80 | 81 | 82 | ``` 83 | 开头几行指定了工程名称和版本号。然后我们添加了一个编译器插件,告知Maven我们的代码要用Java1.6编译。接下来我们定义了Maven仓库(Maven支持为同一个工程指定多个仓库)。clojars是存放Storm依赖的仓库。Maven会为运行本地模式自动下载必要的所有子包依赖。 84 | 85 | 一个典型的Maven Java工程会拥有如下结构: 86 | ``` 87 | 我们的应用目录/ 88 | ├── pom.xml 89 | └── src 90 | └── main 91 | └── java 92 | | ├── spouts 93 | | └── bolts 94 | └── resources 95 | ``` 96 | java目录下的子目录包含我们的代码,我们把要统计单词数的文件保存在resource目录下。 97 | 98 | **NOTE**:命令mkdir -p 会创建所有需要的父目录。 99 | 100 | ###**创建我们的第一个拓扑** 101 | 102 | 我们将为运行单词计数创建所有必要的类。可能这个例子中的某些部分,现在无法讲的很清楚,不过我们会在随后的章节做进一步的讲解。 103 | 104 | ###***Spout*** 105 | 106 | *spout* WordReader类实现了IRichSpout接口。我们将在[第四章][8]看到更多细节。WordReader负责从文件按行读取文本,并把文本行提供给第一个*bolt*。 107 | 108 | **NOTE:** 一个*spout*发布一个定义域列表。这个架构允许你使用不同的*bolts*从同一个*spout*流读取数据,它们的输出也可作为其它*bolts*的定义域,以此类推。 109 | 110 | 例2-1包含WordRead类的完整代码(我们将会分析下述代码的每一部分)。 111 | 112 | ```java 113 | /** 114 | * 例2-1.src/main/java/spouts/WordReader.java 115 | */ 116 | package spouts; 117 | 118 | import java.io.BufferedReader; 119 | import java.io.FileNotFoundException; 120 | import java.io.FileReader; 121 | import java.util.Map; 122 | import backtype.storm.spout.SpoutOutputCollector; 123 | import backtype.storm.task.TopologyContext; 124 | import backtype.storm.topology.IRichSpout; 125 | import backtype.storm.topology.OutputFieldsDeclarer; 126 | import backtype.storm.tuple.Fields; 127 | import backtype.storm.tuple.Values; 128 | 129 | public class WordReader implements IRichSpout { 130 | private SpoutOutputCollector collector; 131 | private FileReader fileReader; 132 | private boolean completed = false; 133 | private TopologyContext context; 134 | public boolean isDistributed() {return false;} 135 | public void ack(Object msgId) { 136 | System.out.println("OK:"+msgId); 137 | } 138 | public void close() {} 139 | public void fail(Object msgId) { 140 | System.out.println("FAIL:"+msgId); 141 | } 142 | /** 143 | * 这个方法做的惟一一件事情就是分发文件中的文本行 144 | */ 145 | public void nextTuple() { 146 | /** 147 | * 这个方法会不断的被调用,直到整个文件都读完了,我们将等待并返回。 148 | */ 149 | if(completed){ 150 | try { 151 | Thread.sleep(1000); 152 | } catch (InterruptedException e) { 153 | //什么也不做 154 | } 155 | return; 156 | } 157 | String str; 158 | //创建reader 159 | BufferedReader reader = new BufferedReader(fileReader); 160 | try{ 161 | //读所有文本行 162 | while((str = reader.readLine()) != null){ 163 | /** 164 | * 按行发布一个新值 165 | */ 166 | this.collector.emit(new Values(str),str); 167 | } 168 | }catch(Exception e){ 169 | throw new RuntimeException("Error reading tuple",e); 170 | }finally{ 171 | completed = true; 172 | } 173 | } 174 | /** 175 | * 我们将创建一个文件并维持一个collector对象 176 | */ 177 | public void open(Map conf, TopologyContext context, SpoutOutputCollector collector) { 178 | try { 179 | this.context = context; 180 | this.fileReader = new FileReader(conf.get("wordsFile").toString()); 181 | } catch (FileNotFoundException e) { 182 | throw new RuntimeException("Error reading file ["+conf.get("wordFile")+"]"); 183 | } 184 | this.collector = collector; 185 | } 186 | /** 187 | * 声明输入域"word" 188 | */ 189 | public void declareOutputFields(OutputFieldsDeclarer declarer) { 190 | declarer.declare(new Fields("line")); 191 | } 192 | } 193 | ``` 194 | 第一个被调用的*spout*方法都是**public void open(Map conf, TopologyContext context, SpoutOutputCollector collector)**。它接收如下参数:配置对象,在定义topology对象是创建;TopologyContext对象,包含所有拓扑数据;还有SpoutOutputCollector对象,它能让我们发布交给*bolts*处理的数据。下面的代码主是这个方法的实现。 195 | ```java 196 | public void open(Map conf, TopologyContext context, 197 | SpoutOutputCollector collector) { 198 | try { 199 | this.context = context; 200 | this.fileReader = new FileReader(conf.get("wordsFile").toString()); 201 | } catch (FileNotFoundException e) { 202 | throw new RuntimeException("Error reading file ["+conf.get("wordFile")+"]"); 203 | } 204 | this.collector = collector; 205 | } 206 | ``` 207 | 我们在这个方法里创建了一个FileReader对象,用来读取文件。接下来我们要实现**public void nextTuple()**,我们要通过它向*bolts*发布待处理的数据。在这个例子里,这个方法要读取文件并逐行发布数据。 208 | ```java 209 | public void nextTuple() { 210 | if(completed){ 211 | try { 212 | Thread.sleep(1); 213 | } catch (InterruptedException e) { 214 | //什么也不做 215 | } 216 | return; 217 | } 218 | String str; 219 | BufferedReader reader = new BufferedReader(fileReader); 220 | try{ 221 | while((str = reader.readLine()) != null){ 222 | this.collector.emit(new Values(str)); 223 | } 224 | }catch(Exception e){ 225 | throw new RuntimeException("Error reading tuple",e); 226 | }finally{ 227 | completed = true; 228 | } 229 | } 230 | ``` 231 | **NOTE:** Values是一个ArrarList实现,它的元素就是传入构造器的参数。 232 | 233 | **nextTuple()**会在同一个循环内被**ack()**和**fail()**周期性的调用。没有任务时它必须释放对线程的控制,其它方法才有机会得以执行。因此nextTuple的第一行就要检查是否已处理完成。如果完成了,为了降低处理器负载,会在返回前休眠一毫秒。如果任务完成了,文件中的每一行都已被读出并分发了。 234 | 235 | **NOTE:**元组(tuple)是一个具名值列表,它可以是任意java对象(只要它是可序列化的)。默认情况,Storm会序列化字符串、字节数组、ArrayList、HashMap和HashSet等类型。 236 | 237 | ###**Bolts** 238 | 239 | 现在我们有了一个*spout*,用来按行读取文件并每行发布一个*元组*,还要创建两个*bolts*,用来处理它们(看图2-1)。*bolts*实现了接口**backtype.storm.topology.IRichBolt**。 240 | 241 | *bolt*最重要的方法是**void execute(Tuple input)**,每次接收到元组时都会被调用一次,还会再发布若干个元组。 242 | 243 | **NOTE:** 只要必要,*bolt*或*spout*会发布若干元组。当调用**nextTuple**或**execute**方法时,它们可能会发布0个、1个或许多个元组。你将在[第五章][9]学习更多这方面的内容。 244 | 245 | 第一个*bolt*,**WordNormalizer**,负责得到并标准化每行文本。它把文本行切分成单词,大写转化成小写,去掉头尾空白符。 246 | 247 | 首先我们要声明*bolt*的出参: 248 | ```java 249 | public void declareOutputFields(OutputFieldsDeclarer declarer){ 250 | declarer.declare(new Fields("word")); 251 | } 252 | ``` 253 | 这里我们声明*bolt*将发布一个名为“word”的域。 254 | 255 | 下一步我们实现**public void execute(Tuple input)**,处理传入的元组: 256 | ```java 257 | public void execute(Tuple input){ 258 | String sentence=input.getString(0); 259 | String[] words=sentence.split(" "); 260 | for(String word : words){ 261 | word=word.trim(); 262 | if(!word.isEmpty()){ 263 | word=word.toLowerCase(); 264 | //发布这个单词 265 | collector.emit(new Values(word)); 266 | } 267 | } 268 | //对元组做出应答 269 | collector.ack(input); 270 | } 271 | ``` 272 | 273 | 第一行从元组读取值。值可以按位置或名称读取。接下来值被处理并用collector对象发布。最后,每次都调用collector对象的**ack()**方法确认已成功处理了一个元组。 274 | 275 | 例2-2是这个类的完整代码。 276 | ```java 277 | //例2-2 src/main/java/bolts/WordNormalizer.java 278 | package bolts; 279 | import java.util.ArrayList; 280 | import java.util.List; 281 | import java.util.Map; 282 | import backtype.storm.task.OutputCollector; 283 | import backtype.storm.task.TopologyContext; 284 | import backtype.storm.topology.IRichBolt; 285 | import backtype.storm.topology.OutputFieldsDeclarer; 286 | import backtype.storm.tuple.Fields; 287 | import backtype.storm.tuple.Tuple; 288 | import backtype.storm.tuple.Values; 289 | public class WordNormalizer implements IRichBolt{ 290 | private OutputCollector collector; 291 | public void cleanup(){} 292 | /** 293 | * *bolt*从单词文件接收到文本行,并标准化它。 294 | * 文本行会全部转化成小写,并切分它,从中得到所有单词。 295 | */ 296 | public void execute(Tuple input){ 297 | String sentence = input.getString(0); 298 | String[] words = sentence.split(" "); 299 | for(String word : words){ 300 | word = word.trim(); 301 | if(!word.isEmpty()){ 302 | word=word.toLowerCase(); 303 | //发布这个单词 304 | List a = new ArrayList(); 305 | a.add(input); 306 | collector.emit(a,new Values(word)); 307 | } 308 | } 309 | //对元组做出应答 310 | collector.ack(input); 311 | } 312 | public void prepare(Map stormConf, TopologyContext context, OutputCollector collector) { 313 | this.collector=collector; 314 | } 315 | 316 | /** 317 | * 这个*bolt*只会发布“word”域 318 | */ 319 | public void declareOutputFields(OutputFieldsDeclarer declarer) { 320 | declarer.declare(new Fields("word")); 321 | } 322 | } 323 | ``` 324 | 325 | **NOTE:**通过这个例子,我们了解了在一次**execute**调用中发布多个元组。如果这个方法在一次调用中接收到句子“This is the Storm book”,它将会发布五个元组。 326 | 327 | 下一个*bolt*,**WordCounter**,负责为单词计数。这个拓扑结束时(**cleanup()**方法被调用时),我们将显示每个单词的数量。 328 | 329 | **NOTE: **这个例子的*bolt*什么也没发布,它把数据保存在map里,但是在真实的场景中可以把数据保存到数据库。 330 | ```java 331 | package bolts; 332 | 333 | import java.util.HashMap; 334 | import java.util.Map; 335 | import backtype.storm.task.OutputCollector; 336 | import backtype.storm.task.TopologyContext; 337 | import backtype.storm.topology.IRichBolt; 338 | import backtype.storm.topology.OutputFieldsDeclarer; 339 | import backtype.storm.tuple.Tuple; 340 | 341 | public class WordCounter implements IRichBolt{ 342 | Integer id; 343 | String name; 344 | Map counters; 345 | private OutputCollector collector; 346 | 347 | /** 348 | * 这个spout结束时(集群关闭的时候),我们会显示单词数量 349 | */ 350 | @Override 351 | public void cleanup(){ 352 | System.out.println("-- 单词数 【"+name+"-"+id+"】 --"); 353 | for(Map.Entry entry : counters.entrySet()){ 354 | System.out.println(entry.getKey()+": "+entry.getValue()); 355 | } 356 | } 357 | 358 | /** 359 | * 为每个单词计数 360 | */ 361 | @Override 362 | public void execute(Tuple input) { 363 | String str=input.getString(0); 364 | /** 365 | * 如果单词尚不存在于map,我们就创建一个,如果已在,我们就为它加1 366 | */ 367 | if(!counters.containsKey(str)){ 368 | conters.put(str,1); 369 | }else{ 370 | Integer c = counters.get(str) + 1; 371 | counters.put(str,c); 372 | } 373 | //对元组做出应答 374 | collector.ack(input); 375 | } 376 | 377 | /** 378 | * 初始化 379 | */ 380 | @Override 381 | public void prepare(Map stormConf, TopologyContext context, OutputCollector collector){ 382 | this.counters = new HashMap(); 383 | this.collector = collector; 384 | this.name = context.getThisComponentId(); 385 | this.id = context.getThisTaskId(); 386 | } 387 | 388 | @Override 389 | public void declareOutputFields(OutputFieldsDeclarer declarer) {} 390 | } 391 | ``` 392 | execute方法使用一个map收集单词并计数。拓扑结束时,将调用**clearup()**方法打印计数器map。(虽然这只是一个例子,但是通常情况下,当拓扑关闭时,你应当使用**cleanup()**方法关闭活动的连接和其它资源。) 393 | 394 | ###**主类** 395 | 396 | 你可以在主类中创建拓扑和一个本地集群对象,以便于在本地测试和调试。**LocalCluster**可以通过**Config**对象,让你尝试不同的集群配置。比如,当使用不同数量的工作进程测试你的拓扑时,如果不小心使用了某个全局变量或类变量,你就能够发现错误。(更多内容请见[第三章][10]) 397 | 398 | **NOTE:**所有拓扑节点的各个进程必须能够独立运行,而不依赖共享数据(也就是没有全局变量或类变量),因为当拓扑运行在真实的集群环境时,这些进程可能会运行在不同的机器上。 399 | 400 | 接下来,**TopologyBuilder**将用来创建拓扑,它决定Storm如何安排各节点,以及它们交换数据的方式。 401 | ```java 402 | TopologyBuilder builder = new TopologyBuilder(); 403 | builder.setSpout("word-reader", new WordReader()); 404 | builder.setBolt("word-normalizer", new WordNormalizer()).shuffleGrouping("word-reader"); 405 | builder.setBolt("word-counter", new WordCounter())..shuffleGrouping("word-normalizer"); 406 | ``` 407 | 在*spout*和*bolts*之间通过**shuffleGrouping**方法连接。这种分组方式决定了Storm会以随机分配方式从源节点向目标节点发送消息。 408 | 409 | 下一步,创建一个包含拓扑配置的**Config**对象,它会在运行时与集群配置合并,并通过**prepare**方法发送给所有节点。 410 | ```java 411 | Config conf = new Config(); 412 | conf.put("wordsFile", args[0]); 413 | conf.setDebug(true); 414 | ``` 415 | 由*spout*读取的文件的文件名,赋值给**wordFile**属性。由于是在开发阶段,设置**debug**属性为**true**,Strom会打印节点间交换的所有消息,以及其它有助于理解拓扑运行方式的调试数据。 416 | 417 | 正如之前讲过的,你要用一个**LocalCluster**对象运行这个拓扑。在生产环境中,拓扑会持续运行,不过对于这个例子而言,你只要运行它几秒钟就能看到结果。 418 | ```java 419 | LocalCluster cluster = new LocalCluster(); 420 | cluster.submitTopology("Getting-Started-Topologie", conf, builder.createTopology()); 421 | Thread.sleep(2000); 422 | cluster.shutdown(); 423 | ``` 424 | 调用**createTopology**和**submitTopology**,运行拓扑,休眠两秒钟(拓扑在另外的线程运行),然后关闭集群。 425 | 426 | 例2-3是完整的代码 427 | ```java 428 | //例2-3 src/main/java/TopologyMain.java 429 | import spouts.WordReader; 430 | import backtype.storm.Config; 431 | import backtype.storm.LocalCluster; 432 | import backtype.storm.topology.TopologyBuilder; 433 | import backtype.storm.tuple.Fields; 434 | import bolts.WordCounter; 435 | import bolts.WordNormalizer; 436 | 437 | public class TopologyMain { 438 | public static void main(String[] args) throws InterruptedException { 439 | //定义拓扑 440 | TopologyBuilder builder = new TopologyBuilder()); 441 | builder.setSpout("word-reader", new WordReader()); 442 | builder.setBolt("word-normalizer", new WordNormalizer()).shuffleGrouping("word-reader"); 443 | builder.setBolt("word-counter", new WordCounter(),2).fieldsGrouping("word-normalizer", new Fields("word")); 444 | 445 | //配置 446 | Config conf = new Config(); 447 | conf.put("wordsFile", args[0]); 448 | conf.setDebug(false); 449 | 450 | //运行拓扑 451 | conf.put(Config.TOPOLOGY_MAX_SPOUT_PENDING, 1); 452 | LocalCluster cluster = new LocalCluster(); 453 | cluster.submitTopology("Getting-Started-Topologie", conf, builder.createTopology(); 454 | Thread.sleep(1000); 455 | cluster.shutdown(); 456 | } 457 | } 458 | ``` 459 | 460 | ###**观察运行情况** 461 | 462 | 你已经为运行你的第一个拓扑准备好了。在这个目录下面创建一个文件,**/src/main/resources/words.txt**,一个单词一行,然后用下面的命令运行这个拓扑:**mvn exec:java -Dexec.mainClass="TopologyMain" -Dexec.args="src/main/resources/words.txt**。 463 | 464 | 举个例子,如果你的*words.txt*文件有如下内容: 465 | **Storm 466 | test 467 | are 468 | great 469 | is 470 | an 471 | Storm 472 | simple 473 | application 474 | but 475 | very 476 | powerful 477 | really 478 | Storm 479 | is 480 | great** 481 | 你应该会在日志中看到类似下面的内容: 482 | **is: 2 483 | application: 1 484 | but: 1 485 | great: 1 486 | test: 1 487 | simple: 1 488 | Storm: 3 489 | really: 1 490 | are: 1 491 | great: 1 492 | an: 1 493 | powerful: 1 494 | very: 1** 495 | 在这个例子中,每类节点只有一个实例。但是如果你有一个非常大的日志文件呢?你能够很轻松的改变系统中的节点数量实现并行工作。这个时候,你就要创建两个**WordCounter**实例。 496 | ```java 497 | builder.setBolt("word-counter", new WordCounter(),2).shuffleGrouping("word-normalizer"); 498 | ``` 499 | 程序返回时,你将看到: 500 | **-- 单词数 【word-counter-2】 -- 501 | application: 1 502 | is: 1 503 | great: 1 504 | are: 1 505 | powerful: 1 506 | Storm: 3 507 | -- 单词数 [word-counter-3] -- 508 | really: 1 509 | is: 1 510 | but: 1 511 | great: 1 512 | test: 1 513 | simple: 1 514 | an: 1 515 | very: 1** 516 | 棒极了!修改并行度实在是太容易了(当然对于实际情况来说,每个实例都会运行在单独的机器上)。不过似乎有一个问题:单词*is*和*great*分别在每个**WordCounter**各计数一次。怎么会这样?当你调用**shuffleGrouping**时,就决定了Storm会以随机分配的方式向你的*bolt*实例发送消息。在这个例子中,理想的做法是相同的单词问题发送给同一个**WordCounter**实例。你把**shuffleGrouping("word-normalizer")**换成**fieldsGrouping("word-normalizer", new Fields("word"))**就能达到目的。试一试,重新运行程序,确认结果。 你将在后续章节学习更多分组方式和消息流类型。 517 | 518 | ###**结论** 519 | 520 | 我们已经讨论了Storm的本地和远程操作模式之间的不同,以及Storm的强大和易于开发的特性。你也学习了一些Storm的基本概念,我们将在后续章节深入讲解它们。 521 | 522 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Figure%202-1.%20Getting%20started%20topology.png 523 | [2]: https://github.com/%20storm-book/examples-ch02-getting_started/zipball/master 524 | [3]: http://git-scm.com/ 525 | [4]: http://www.java.com/download/ 526 | [5]: http://maven.apache.org/ 527 | [6]: http://maven.apache.org/download.html 528 | [7]: http://maven.apache.org/ 529 | [8]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter4/Spouts.md 530 | [9]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter5/Bolts.md 531 | [10]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter3/Topologies.md 532 | -------------------------------------------------------------------------------- /chapter2/Operation Modes.md: -------------------------------------------------------------------------------- 1 | **操作模式** 2 | 3 | 开始之前,有必要了解一下Storm的操作模式。有下面两种方式。 4 | 5 | **本地模式** 6 | 7 | 在本地模式下,Storm拓扑结构运行在本地计算机的单一JVM进程上。这个模式用于开发、测试以及调试,因为这是观察所有组件如何协同工作的最简单方法。在这种模式下,我们可以调整参数,观察我们的拓扑结构如何在不同的Storm配置环境下运行。要在本地模式下运行,我们要下载Storm开发依赖,以便用来开发并测试我们的拓扑结构。我们创建了第一个Storm工程以后,很快就会明白如何使用本地模式了。 8 | 9 | **NOTE**: 在本地模式下,跟在集群环境运行很像。不过很有必要确认一下所有组件都是线程安全的,因为当把它们部署到远程模式时它们可能会运行在不同的JVM进程甚至不同的物理机上,这个时候它们之间没有直接的通讯或共享内存。 10 | 11 | 我们要在本地模式运行本章的所有例子。 12 | 13 | 14 | **远程模式** 15 | 16 | 17 | 在远程模式下,我们向Storm集群提交拓扑,它通常由许多运行在不同机器上的流程组成。远程模式不会出现调试信息, 因此它也称作生产模式。不过在单一开发机上建立一个Storm集群是一个好主意,可以在部署到生产环境之前,用来确认拓扑在集群环境下没有任何问题。 18 | 19 | 你将在[第六章][1]学到更多关于远程模式的内容,并在[附录B][2]学到如何安装一个Storm集群。 20 | 21 | 22 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter6/A%20RealLife%20Example.md 23 | [2]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/appendix/B.md 24 | -------------------------------------------------------------------------------- /chapter3/Figure3-1 DRPC Topology schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runfriends/GettingStartedWithStorm-cn/dfcfb7c5bb8f8a8ea20baa1e56f4ce1a47bd0b5b/chapter3/Figure3-1 DRPC Topology schema.png -------------------------------------------------------------------------------- /chapter3/拓扑.md: -------------------------------------------------------------------------------- 1 | 在这一章,你将学到如何在同一个Storm拓扑结构内的不同组件之间传递元组,以及如何向一个运行中的Storm集群发布一个拓扑。 2 | 3 | ###**数据流组** 4 | 设计一个拓扑时,你要做的最重要的事情之一就是定义如何在各组件之间交换数据(数据流是如何被*bolts*消费的)。一个*数据流组*指定了每个*bolt*会消费哪些数据流,以及如何消费它们。 5 | 6 | **NOTE**:一个节点能够发布一个以上的数据流,一个数据流组允许我们选择接收哪个。 7 | 8 | 数据流组在定义拓扑时设置,就像我们在[第二章][1]看到的: 9 | ```java 10 | ··· 11 | builder.setBolt("word-normalizer", new WordNormalizer()) 12 | .shuffleGrouping("word-reader"); 13 | ··· 14 | ``` 15 | 在前面的代码块里,一个*bolt*由**TopologyBuilder**对象设定, 然后使用随机数据流组指定数据源。数据流组通常将数据源组件的ID作为参数,取决于数据流组的类型不同还有其它可选参数。 16 | 17 | **NOTE:**每个**InputDeclarer**可以有一个以上的数据源,而且每个数据源可以分到不同的组。 18 | 19 | ###**随机数据流组** 20 | 随机流组是最常用的数据流组。它只有一个参数(数据源组件),并且数据源会向随机选择的*bolt*发送元组,保证每个消费者收到近似数量的元组。 21 | 22 | 随机数据流组用于数学计算这样的原子操作。然而,如果操作不能被随机分配,就像[第二章][2]为单词计数的例子,你就要考虑其它分组方式了。 23 | 24 | ###**域数据流组** 25 | 域数据流组允许你基于元组的一个或多个域控制如何把元组发送给*bolts*。它保证拥有相同域组合的值集发送给同一个的*bolt*。回到单词计数器的例子,如果你用*word*域为数据流分组,**word-normalizer** *bolt*将只会把相同单词的元组发送给同一个**word-counter** *bolt*实例。 26 | ```java 27 | ··· 28 | builder.setBolt("word-counter", new WordCounter(),2) 29 | .fieldsGrouping("word-normalizer", new Fields("word")); 30 | ··· 31 | ``` 32 | **NOTE:** 在域数据流组中的所有域集合必须存在于数据源的域声明中。 33 | 34 | ###**全部数据流组** 35 | 全部数据流组,为每个接收数据的实例复制一份元组副本。这种分组方式用于向*bolts*发送信号。比如,你要刷新缓存,你可以向所有的*bolts*发送一个*刷新缓存信号*。在单词计数器的例子里,你可以使用一个全部数据流组,添加清除计数器缓存的功能(见[拓扑示例][3]) 36 | ```java 37 | public void execute(Tuple input) { 38 | String str = null; 39 | try{ 40 | if(input.getSourceStreamId().equals("signals")){ 41 | str = input.getStringByField("action"); 42 | if("refreshCache".equals(str)) 43 | counters.clear(); 44 | } 45 | }catch (IllegalArgumentException e){ 46 | //什么也不做 47 | } 48 | ··· 49 | } 50 | ``` 51 | 我们添加了一个if分支,用来检查源数据流。Storm允许我们声明具名数据流(如果你不把元组发送到一个具名数据流,默认发送到"**default**")。这是一个识别元组的极好的方式,就像这个例子中,我们想识别**signals**一样。 52 | 在拓扑定义中,你要向**word-counter** *bolt*添加第二个数据流,用来接收从**signals-spout**数据流发送到所有*bolt*实例的每一个元组。 53 | ```java 54 | builder.setBolt("word-counter", new WordCounter(),2) 55 | .fieldsGroupint("word-normalizer",new Fields("word")) 56 | .allGrouping("signals-spout","signals"); 57 | ``` 58 | **signals-spout**的实现请参考[git仓库][4]。 59 | 60 | ###**自定义数据流组** 61 | 你可以通过实现**backtype.storm.grouping.CustormStreamGrouping**接口创建自定义数据流组,让你自己决定哪些*bolt*接收哪些元组。 62 | 63 | 让我们修改单词计数器示例,使首字母相同的单词由同一个*bolt*接收。 64 | ```java 65 | public class ModuleGrouping mplents CustormStreamGrouping, Serializable{ 66 | int numTasks = 0; 67 | 68 | @Override 69 | public List chooseTasks(List values) { 70 | List boltIds = new ArrayList(); 71 | if(values.size()>0){ 72 | String str = values.get(0).toString(); 73 | if(str.isEmpty()){ 74 | boltIds.add(0); 75 | }else{ 76 | boltIds.add(str.charAt(0) % numTasks); 77 | } 78 | } 79 | return boltIds; 80 | } 81 | 82 | @Override 83 | public void prepare(TopologyContext context, Fields outFields, List targetTasks) { 84 | numTasks = targetTasks.size(); 85 | } 86 | } 87 | ``` 88 | 这是一个**CustomStreamGrouping**的简单实现,在这里我们采用单词首字母字符的整数值与任务数的余数,决定接收元组的*bolt*。 89 | 90 | 按下述方式**word-normalizer**修改即可使用这个自定义数据流组。 91 | ```java 92 | builder.setBolt("word-normalizer", new WordNormalizer()) 93 | .customGrouping("word-reader", new ModuleGrouping()); 94 | ``` 95 | 96 | ###**直接数据流组** 97 | 这是一个特殊的数据流组,数据源可以用它决定哪个组件接收元组。与前面的例子类似,数据源将根据单词首字母决定由哪个*bolt*接收元组。要使用直接数据流组,在**WordNormalizer** *bolt*中,使用**emitDirect**方法代替**emit**。 98 | ```java 99 | public void execute(Tuple input) { 100 | ... 101 | for(String word : words){ 102 | if(!word.isEmpty()){ 103 | ... 104 | collector.emitDirect(getWordCountIndex(word),new Values(word)); 105 | } 106 | } 107 | //对元组做出应答 108 | collector.ack(input); 109 | } 110 | 111 | public Integer getWordCountIndex(String word) { 112 | word = word.trim().toUpperCase(); 113 | if(word.isEmpty()){ 114 | return 0; 115 | }else{ 116 | return word.charAt(0) % numCounterTasks; 117 | } 118 | } 119 | ``` 120 | 在**prepare**方法中计算任务数 121 | ```java 122 | public void prepare(Map stormConf, TopologyContext context, 123 | OutputCollector collector) { 124 | this.collector = collector; 125 | this.numCounterTasks = context.getComponentTasks("word-counter"); 126 | } 127 | ``` 128 | 在拓扑定义中指定数据流将被直接分组: 129 | ```java 130 | builder.setBolt("word-counter", new WordCounter(),2) 131 | .directGrouping("word-normalizer"); 132 | ``` 133 | ###**全局数据流组** 134 | 全局数据流组把所有数据源创建的元组发送给单一目标实例(即拥有最低ID的任务)。 135 | 136 | ###**不分组** 137 | 写作本书时(Stom0.7.1版),这个数据流组相当于随机数据流组。也就是说,使用这个数据流组时,并不关心数据流是如何分组的。 138 | 139 | ###**LocalCluster VS StormSubmitter** 140 | 到目前为止,你已经用一个叫做**LocalCluster**的工具在你的本地机器上运行了一个拓扑。Storm的基础工具,使你能够在自己的计算机上方便的运行和调试不同的拓扑。但是你怎么把自己的拓扑提交给运行中的Storm集群呢?Storm有一个有趣的功能,在一个真实的集群上运行自己的拓扑是很容易的事情。要实现这一点,你需要把**LocalCluster**换成**StormSubmitter**并实现**submitTopology**方法, 它负责把拓扑发送给集群。 141 | 142 | 下面是修改后的代码: 143 | ```java 144 | //LocalCluster cluster = new LocalCluster(); 145 | //cluster.submitTopology("Count-Word-Topology-With-Refresh-Cache", conf, 146 | //builder.createTopology()); 147 | StormSubmitter.submitTopology("Count-Word-Topology-With_Refresh-Cache", conf, 148 | builder.createTopology()); 149 | //Thread.sleep(1000); 150 | //cluster.shutdown(); 151 | ``` 152 | **NOTE:** 当你使用**StormSubmitter**时,你就不能像使用**LocalCluster**时一样通过代码控制集群了。 153 | 154 | 接下来,把源码压缩成一个jar包,运行Storm客户端命令,把拓扑提交给集群。如果你已经使用了Maven, 你只需要在命令行进入源码目录运行:**mvn package**。 155 | 156 | 现在你生成了一个jar包,使用**storm jar**命令提交拓扑(关于如何安装Storm客户端请参考[附录A][5])。命令格式:**storm jar allmycode.jar org.me.MyTopology arg1 arg2 arg3**。 157 | 158 | 对于这个例子,在拓扑工程目录下面运行: 159 | ``` 160 | storm jar target/Topologies-0.0.1-SNAPSHOT.jar countword.TopologyMain src/main/resources/words.txt 161 | ``` 162 | 通过这些命令,你就把拓扑发布集群上了。 163 | 164 | 如果想停止或杀死它,运行: 165 | ``` 166 | storm kill Count-Word-Topology-With-Refresh-Cache 167 | ``` 168 | **NOTE:**拓扑名称必须保证惟一性。 169 | **NOTE:**如何安装Storm客户端,参考[附录A][6] 170 | ###**DRPC拓扑** 171 | 有一种特殊的拓扑类型叫做分布式远程过程调用(DRPC),它利用Storm的分布式特性执行远程过程调用(RPC)(见下图)。Storm提供了一些用来实现DRPC的工具。第一个是DRPC服务器,它就像是客户端和Storm拓扑之间的连接器,作为拓扑的*spout*的数据源。它接收一个待执行的函数和函数参数,然后对于函数操作的每一个数据块,这个服务器都会通过拓扑分配一个请求ID用来识别RPC请求。拓扑执行最后的*bolt*时,它必须分配RPC请求ID和结果,使DRPC服务器把结果返回正确的客户端。 172 | ![图3-1DRPC拓扑图][7] 173 | **NOTE:**单实例DRPC服务器能够执行许多函数。每个函数由一个惟一的名称标识。 174 | 175 | Storm提供的第二个工具(已在例子中用过)是**LineDRpctopologyBuilder**,一个辅助构建DRPC拓扑的抽象概念。生成的拓扑创建**DRPCSpouts**——它连接到DRPC服务器并向拓扑的其它部分分发数据——并包装*bolts*,使结果从最后一个*bolt*返回。依次执行所有添加到**LinearDRPCTopologyBuilder**对象的*bolts*。 176 | 177 | 作为这种类型的拓扑的一个例子,我们创建了一个执行加法运算的进程。虽然这是一个简单的例子,但是这个概念可以被扩展到用于执行复杂的分布式计算。 178 | 179 | *bolt*按下面的方式声明输出: 180 | ```java 181 | public void declareOutputFields(OutputFieldsDeclarer declarer) { 182 | declarer.declare(new Fields("id","result")); 183 | } 184 | ``` 185 | 因为这是拓扑中惟一的*bolt*,它必须发布RPC ID和结果。**execute**方法负责执行加法运算。 186 | ```java 187 | public void execute(Tuple input) { 188 | String[] numbers = input.getString(1).split("\\+"); 189 | Integer added = 0; 190 | if(numbers.length<2){ 191 | throw new InvalidParameterException("Should be at least 2 numbers"); 192 | } 193 | for(String num : numbers){ 194 | added += Integer.parseInt(num); 195 | } 196 | collector.emit(new Values(input.getValue(0),added)); 197 | } 198 | ``` 199 | 包含加法*bolt*的拓扑定义如下: 200 | ```java 201 | public static void main(String[] args) { 202 | LocalDRPC drpc = new LocalDRPC(); 203 | 204 | LinearDRPCTopologyBuilder builder = new LinearDRPCTopologyBuilder("add"); 205 | builder.addBolt(AdderBolt(),2); 206 | 207 | Config conf = new Config(); 208 | conf.setDebug(true); 209 | 210 | LocalCluster cluster = new LocalCluster(); 211 | cluster.submitTopology("drpcder-topology", conf, 212 | builder.createLocalTopology(drpc)); 213 | String result = drpc.execute("add", "1+-1"); 214 | checkResult(result,0); 215 | result = drpc.execute("add", "1+1+5+10"); 216 | checkResult(result,17); 217 | 218 | cluster.shutdown(); 219 | drpc.shutdown(); 220 | } 221 | ``` 222 | 创建一个**LocalDRPC**对象在本地运行DRPC服务器。接下来,创建一个拓扑构建器(译者注:LineDRpctopologyBuilder对象),把*bolt*添加到拓扑。运行DRPC对象(LocalDRPC对象)的**execute**方法测试拓扑。 223 | 224 | **NOTE:**使用**DRPCClient**类连接远程DRPC服务器。DRPC服务器暴露了[Thrift API][8],因此可以跨语言编程;并且不论是在本地还是在远程运行DRPC服务器,它们的API都是相同的。 225 | 对于采用Storm配置的DRPC配置参数的Storm集群,调用构建器对象的**createRemoteTopology**向Storm集群提交一个拓扑,而不是调用**createLocalTopology**。 226 | 227 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Getting%20Started.md 228 | [2]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Getting%20Started.md 229 | [3]: https://github.com/storm-book/examples-ch03-topologies 230 | [4]: https://github.com/storm-book/examples-ch03-topologies 231 | [5]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/appendix/A.md 232 | [6]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/appendix/A.md 233 | [7]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter3/Figure3-1%20DRPC%20Topology%20schema.png 234 | [8]: http://thrift.apache.org/ 235 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | 第一章 基础知识 2 | ---------- 3 | 4 | [基础知识][1] 5 | 6 | [Storm的组件][2] 7 | 8 | [Storm的特性][3] 9 | 10 | ========== 11 | 12 | 第二章 准备开始 13 | ---------- 14 | [准备开始][4] 15 | 16 | [操作模式][5] 17 | 18 | [Hello World][6] 19 | 20 | 21 | [1]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter1/Basics.md 22 | [2]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter1/The%20Components%20of%20Storm.md 23 | [3]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter1/The%20Properties%20of%20Storm.md 24 | [4]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Getting%20Started.md 25 | [5]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Operation%20Modes.md 26 | [6]: https://github.com/runfriends/GettingStartedWithStorm-cn/blob/master/chapter2/Hello%20World%20Storm.md 27 | --------------------------------------------------------------------------------