Research on NATS

NATS是CloudFoundry内部的神经系统,是一款基于EventMachine、使用“发布--订阅”机制的轻量级消息中间件。基于EM的特点使得NATS在Ruby环境下有着处理高并发请求的能力。NATS对消息本身不做持久化,所以匹配和订阅的过程比较简洁高效。目前,NATS server是CF中一处需要解决的单点依赖。


NATS主要的依赖gem包包括:eventmachine, json_pure, daemons, thin


NATS的代码结构比较简单:

源码地址: https://github.com/derekcollison/nats/

lib
nats/client.rb                         NATS的客户端
nats/server.rb                       启动NATS server的入口类
nats/server/server.rb           NATS server的实际代码
nats/server/connection.rb  网络连接和消息处理的部分
nats/server/sublist.rb          订阅者管理的部分
nats/server/options.rb         NATS的启动选项
nats/ext/                                 对JRuby和Ruby1.8的补充部分

另外,在github上的cluster分支下,我们还可以看到NATS Cluster的代码:


lib
nats/server/cluster.rb     NATS server集群的代码
nats/server/route.rb        NATS路由集群的代码

NOTE:在阅读代码之前,我们最好先按照github上readme部分熟悉下NATS的使用。


一、/lib/nats/server.rb


这是是server包装类,它通过EventMachine启动了两个服务。

1、Nats Server,基于EM的核心消息服务器
NATSD::Server.start_http_server

EventMachine::start_server(NATSD::Server.host, NATSD::Server.port, NATSD::Connection)

这里,我们就可以看到NATS与EM的密切关系,请尝试对比一下下面两段代码:


Research on NATS_第1张图片


左边部分是我们之前见过的启动一个”Echo server“的简单例子,而右边则是NATS中启动核心NATS server的方法。没错NATS就是基于EM的网络编程的典型例子。在接下来的NATS#connection部分,我们还可以看到更多这样的例子。


2、HTTP Monitor Server,一个基于Thin的监控服务器。跟CF其他组件一样,这个monitor server主要用来响应/varz和/healthz请求。


 # Check to see if we need to fire up the http monitor port and server
  if NATSD::Server.options[:http_port]
    begin
      NATSD::Server.start_http_server
    rescue => e
      log "Could not start monitoring server on port #{NATSD::Server.options[:http_port]}"
      log_error
      exit(1)
    end
  end


查看/lib/nats/server/options.rb中关于:http_port的定义,我们可以看到启动monitor的方法:

opts.on("-m", "--http_port PORT", "Use HTTP PORT ")。没错,就是加参数-m。

比如,我们访问localhost:http_port/varz,可以看到这个nats-server的信息。

{
  "in_bytes": 0,
  "out_bytes": 0,
  "cpu": 3.2,
  "start": "Tue Oct 09 17:15:54 +0800 2012",
  "connections": 0,
  "options": {
    "max_pending": 2000000,
    "users": [
      {
        "pass": "nats",
        "user": "nats"
      }
    ],
 ... ...

而如果我们访问/healthz,会收到一个“ok”,这与CloudFoundry component的机制也是类似的:

@healthz = "ok\n"

... ...

map '/healthz' do
  run lambda { |env| [200, RACK_TEXT_HDR, NATSD::Server.healthz] }
end


二、核心数据机构——Sublist


Sublist用来描述订阅了某个topic的订阅者(subs)有哪些。subs注册的topic以点号分隔成为token,NATS支持两种通配符:>和*,他们的定义如下

# "*" matches any token, at any level of the subject. like *.foo foo.*bar
# ">" matches any length of the tail of a subject and can only be the last token# E.g. 'foo.>' will match 'foo.bar', 'foo.bar.baz', 'foo.foo.bar.bax.22'

比如:

发布者的主题为foo.bar,那么订阅了foo.bar和foo.*的订阅者都是会响应的订阅者

发布者的主题为foo.bar.hoge,那么订阅了foo.>的订阅者是会响应的订阅者,而定于了foo.*的订阅者则是无效的

NATS的主题匹配的机制是通过两种数据结构来描述的:level和node


SublistLevel = Struct.new(:nodes, :pwc, :fwc)

SublistNode  = Struct.new(:leaf_nodes, :next_level)

如果想搞清楚上述数据结构的含义,我们不妨从下面添加订阅者的Sublist#insert方法来入手大胆猜想一下:

...
    for token in tokens
      # This is slightly slower than direct if statements, but looks cleaner.
      case token
        when FWC then node = (level.fwc || (level.fwc = SublistNode.new([])))
        when PWC then node = (level.pwc || (level.pwc = SublistNode.new([])))
        else node = ((level.nodes[token]) || (level.nodes[token] = SublistNode.new([])))
      end
      level = (node.next_level || (node.next_level = SublistLevel.new({})))
    end
    node.leaf_nodes.push(subscriber)
...
通过阅读下面的insert方法,我们分析出Node与Level的关系应该是下面这个样子:

Research on NATS_第2张图片


结合上图,我们可以看到:


level存储的内容包括

  • pwc ,由*匹配的节点
  • fwc ,由>指向的节点
  • nodes,一个Hash,key是token,value是对应的node

node存储的内容包括:

  • leaf_nodes,匹配到该node所属的level为止时,主题对应的所有订阅者
  • next_level,可以进一步进行匹配的节点

所以,当NATS添加一个订阅者时,首先需要遍历该sub的主题:

1、然后从sublist的root开始,查看每一个level中是否有该token对应的node,比如node1=nodes['A'](或者是与通配符关联的,比如node3),记录下这个node的next_level。当然,如果当前的sublist里没有这样的node的话,直接新建出来。

2、继续遍历。如果主题的token都已经遍历完了,就直接在上次遍历得到的——比如node1——的leaf_nodes中添加本次参加订阅的所有订阅者(subs)

3、否则的话,按照next_level继续进行遍历,直到2步中遍历结束的情况出现


当插入操作结束后,Sublist的结构大致如下图所示。


Research on NATS_第3张图片


而当NATS需要按照指定主题找到对应的subs时,实际上是在上图按照下面的顺序进行搜索:(每个token一个迭代)

token -> node -> level -> token ->  node -> level -> ... -> token -> node-> leaf_nodes,好了,这个leaf_nodes数组就是订阅了token.token...token这个主题的订阅者集合了。



在这样的机制下,通配符和非通配符的查询情况是完全类似的,只是如果一个token是pwc的话,就应该在2步中匹配到订阅任何token的nodes;而fwc意味着这个token必然是一个主题的最后一个token,而我们只要拿到这个fwc关联的node的leaf_nodes就可以了。

有了上述sublist建立的基础,下面这段匹配方法的实现就很好理解了:


  ...  
    tokens = subject.split('.')
    matchAll(@root, tokens)
  ...
  
  def matchAll(level, tokens)
    node, pwc = nil, nil # Define for scope
    i, ts = 0, tokens.size
    while (i < ts) do
      return if level == nil
      # Handle a full wildcard here by adding all of the subscribers.
      @results.concat(level.fwc.leaf_nodes) if level.fwc
      # Handle an internal partial wildcard by branching recursively
      lpwc = level.pwc
      matchAll(lpwc.next_level, tokens[i+1, ts]) if lpwc
      node, pwc = level.nodes[tokens[i]], lpwc
      #level = node.next_level if node
      level = node ? node.next_level : nil
      i += 1
    end
    @results.concat(pwc.leaf_nodes) if pwc
    @results.concat(node.leaf_nodes) if node
  end

当我们请求获得某一个主题的订阅者列表时,不管请求方提供的主题是怎样的,实际的匹配过程都可以分成三种情况:

1、遍历到的各level中不含通配符。这时的匹配过程就是遍历tokens数组,按照前面提到的顺序找到最后一个node,该node上记录的leaf_nodes就是我们需要的订阅者集合。

2、遍历到的level中包含*通配符。此时:

如果已经是最后一趟遍历(e.g. Sublist 记录了订阅了A.*的subs,然后有一个A.B的匹配请求),循环就会跳出并且result被赋值为最后一次遍历时pwc指向的node的leaf_nodes。

如果遍历尚未结束(e.g. Sublist 记录了订阅了A.*.B 的subs,然后有一个A.C.B的匹配请求),我们会从*后面的tokens和level开始递归进行同样的匹配操作,这个过程会一直进行到得到最终的订阅者才会回溯。

回溯到原来的代码段,.B以及后面的部分的subs已经找到并放到results中了。接下来继续从C开始查询sublist,所以有类似A.C.B这样的订阅者,也会同样被添加到results中。如果从这层level里查询不到C主题关联的node,遍历也会被结束。

3、遍历到的level中包含>通配符。这种最好处理,由于已经是最后一个token了,所以只要拿到这个fwc关联的node的leaf_nodes就可以了

Sublist的结构其实比较简单,但是我们介绍的还是比较罗嗦,大家最好自己举几个例子走下上面的流程,相信Sublist就会清晰很多。



三、/lib/nats/server/server.rb

这是server具体实现类,server的主要功能都在这个部分有描述。


1、订阅和取消订阅
    
def subscribe(sub)
  @sublist.insert(sub.subject, sub)
end

def unsubscribe(sub)
  @sublist.remove(sub.subject, sub)
end



2、将一个消息交给某个订阅者

def deliver_to_subscriber(sub, subject, reply, msg)

这里需要使用到../server/connection.rb中的方法:

conn.queue_data("MSG #{subject} #{sub.sid} #{reply}#{msg.bytesize}#{CR_LF}#{msg}#{CR_LF}")

connection.rb在后面还有介绍,这里只看相关的方法。

    def flush_data
      return if @writev.nil? || closing?
      send_data(@writev.join)
      @writev, @writev_size = nil, 0
    end 

   def queue_data(data)
      EM.next_tick { flush_data } if @writev.nil?
      (@writev ||= []) << data
      @writev_size += data.bytesize
      flush_data if @writev_size > MAX_WRITEV_SIZE
    end

回忆一下EM#next_tick的知识:next_tick方法负责将一个块调度到Reactor的下一次迭代中执行,执行任务的线程是Reactor主线程。其实,在这里,我们就是为了执行到send_data方法从而将data发送出去。

3、将某个消息路由给所有订阅者


对应的方法是route_to_subscribers(subject, reply, msg),这个方法的主要部分如下:

   ... ...   
       @sublist.match(subject).each do |sub|
          # Skip anyone in the closing state
          next if sub.conn.closing

          unless sub[:qgroup]
            deliver_to_subscriber(sub, subject, reply, msg)
          else
            if NATSD::Server.trace_flag?
              trace("Matched queue subscriber", sub[:subject], sub[:qgroup], sub[:sid], sub.conn.client_info)
            end
            # Queue this for post processing
            qsubs ||= Hash.new
            qsubs[sub[:qgroup]] ||= []
            qsubs[sub[:qgroup]] << sub
          end
        end
  
        return unless qsubs

        qsubs.each_value do |subs|
          # Randomly pick a subscriber from the group
          sub = subs[rand*subs.size]
          if NATSD::Server.trace_flag?
            trace("Selected queue subscriber", sub[:subject], sub[:qgroup], sub[:sid], sub.conn.client_info)
          end
          deliver_to_subscriber(sub, subject, reply, msg)
        end
   ... ...

我们可以看到,这个方法的核心是按照subject遍历sublist,进而调用deliver_to_subscriber来实现路由过程。

注意,这里有一个queue subscriber的判断,如果需要用到qgroup的话,nats首先生成qsubs,然后随机从group中取一个subscriber进行是路由。

关于queue subscriber的使用README里有说明,其实就是一组订阅了同一主题的subs分为一个group,然后按照上面的机制挑选一个sub进行消息转发。


4、启动前面Http监控服务器

http_server = Thin::Server.new(@options[:http_net], port, :signals => false)

四、/lib/nats/server/connection.rb

connection主要负责接收和分析数据,它重写了EM#Connection类中的方法,所以nats connection被作为前面NATS server的连接参数传给EM。关于EM中connectioin的作用可以参考EventMachine的有关文章。

前面已经提到的queue_data,我们这里重点介绍其receive_data方法。

Connection有两种模式:等待控制信息(AWAITING_CONTROL_LINE) 等待数据消息(AWAITING_MSG_PAYLOAD)

Connection初始处于AWAITING_CONTROL_LINE等待着控制信息。

控制信息类型:

PUB_OP、SUB_OP、UNSUB_OP、PING、PONG、CONNECT、INFO、UNKONWN

我们这里仅介绍两个最重要的控制信息的处理过程:


PUB_OP:


控制信息格式:PUB MSG_SUB MSG_REPLY MSG_SIZE

其中MSG_SUB是topic,MSG_REPLY是消息的响应,MSG_SIZE是消息的长度

当收到这个控制信息之后:

1、connection会转入到AWAITING_MSG_PAYLOAD模式

@parse_state = AWAITING_MSG_PAYLOAD
2、在AWAITING_MSG_PAYLOAD模式下,connection收到客户端发来的消息,于是截取发布的消息内容

msg = @buf.slice(0, @msg_size)
3、然后路由给订阅过的订阅者进行处理

Server.route_to_subscribers(@msg_sub, @msg_reply, msg)

4、处理之后仍然恢复为AWAITING_CONTROL_LINE模式,继续等待着新的控制信息的到来。



SUB_OP

控制信息格式:SUB MSG_SUB QGROUP SID

其中,MSG_SUB是消息的topic,QGROUP 是queue group信息,SID是订阅连接的ID

在收到这个控制信息后:

1、根据MSG_SUB、QGROUP以及SID生成新的Subscriber,subscriber对象包括其要注册的topic
subscriber = Subscriber.new(self, sub, sid, qgroup, 0) 

2、将subscriber加入由sid指定的server端的订阅者集合中

@subscriptions[sid] = sub


3、调用Server#subscribe将该subscriber添加到Sublist中,以便将来进行按主题查询

Server.subscribe(sub)


subscribe方法实际上就是调用了sublist#insert方法

 


五、NATS的client端


Client端是负责向NATS server发送操作命令的部分,我们仅解释下其中几个方法:


1、start方法中,Client端需要建立一个到server端的连接:

EM.run { @client = connect(*args, &blk) }

并把这个连接的句柄作为@client变量。

2、而connect方法则利用EM的connect方法建立到NATS server的连接:

client = EM.connect(@uri.host, @uri.port, self, opts)
client.on_connect(&blk) if blk
return client

当这个连接建立成功之后,关联的block才会执行到。

3、subscribe方法在client端保存订阅者信息,并负责向NATS server发送SUB命令。

 
  def subscribe(subject, opts={}, &callback)
    return unless subject
    sid = (@ssid += 1)
    sub = @subs[sid] = { :subject => subject, :callback => callback, :received => 0 }
    sub[:queue] = opts[:queue] if opts[:queue]
    sub[:max] = opts[:max] if opts[:max]
    send_command("SUB #{subject} #{opts[:queue]} #{sid}#{CR_LF}")
    # Setup server support for auto-unsubscribe
    unsubscribe(sid, opts[:max]) if opts[:max]
    sid
  end

参考send_commant方法,其他的控制信息也是通过类似的send_command方法发给server端的。


六、其他

NATS的源码分析就到这里了,但是要真正理解NATS的话,还需要多做实验,多看EventMachine的原理。其实CloudFoundry之所以选择NATS这样的轻量级消息中间件,更多的考虑应该是这种基于Ruby和EM的代码在高并发时的良好表现,以及Ruby本身简洁高效的网络编程技术。这两点在分布式系统的设计中都是很重要的因素。

不过如果是企业私有云考虑的话,稳定性和可靠性往往要占到很高的比重,所以单节点的NATS很容易让人忧心忡忡。鉴于NATS官方的cluster还在beta版,且没有明确的deadline,所以国外有人曾建议过如下几种替代方案:

– AMQP(RabbitMQ)
– Storm
– RestMS
– XMPP

中间件选型这个课题就比较复杂了,不过我们应该考虑的方面应该要包括Ruby客户端的支持,Sub/Pub消息机制,通配符的支持,基于server或broker的工作方式等几个方面吧。

你可能感兴趣的:(Research on NATS)