Ruby 阅读 God 源码心得

redemption · 2015年10月19日 · 最后由 zbjdonald 回复于 2015年11月27日 · 5739 次阅读
本帖已被管理员设置为精华贴

大家好,我是 Ruby 新人,同时也是 Web 编程的新手。虽然我从去年开始断断续续接触 Ruby,但是真的进入 Ruby 的世界是从今年 3 月份在思客远程实习后正式开始的。在这半年多的时间中,我在社区学到了很多东西,同时也提了不少很 “二” 的问题。

虽然作为一个新人,但我也不想老是向别人索取。我也想跟大家分享有趣有用的东西,也能融入这个地方。所以这篇文章就出现了。

这篇文章的定位是:给想要阅读 God 的源码提供一个大概的框架。

God 的整体结构

  • 运行结构

God 程序从运行上看,其结构是 C/S 结构,Client 端用于从命令行中去控制 God 的行为,Server 端进行进程监控。God 的 C/S 是通过 DRB 实现的。

  • 代码组织结构

从代码组织上看,God 的结构是分层结构,每一层代表一个层次的逻辑。低一层的逻辑层次为高一层的逻辑层次的实现提供支持。God 的源码粗略能够分为 3 层。如下图所示:

最高层次的逻辑就是 God module 本身抽象出的逻辑。其表示了 God 所要完成的功能。

次一层的逻辑就是由 Contact、Task(Watch)、EventHandler、Socket 这些类所表示的逻辑。每一个类抽象了 God 所具有的某一方面的功能。Contact 抽象了 God 的通知的功能,Task(Watch)抽象了 God 进程监视功能,EventHandler 抽象了 God 使用 Kqueue 和 Netlink 机制的功能,而 Socket 抽象了与命令行交互的接口。

最低层的逻辑是由 System module、Contacts module、Conditions module、CLI module 和 Driver、Metric、Process、Condition、Behavior 等等构成的,其功能就是为实现上一层逻辑提供支持。

God 源码中比较重要的抽象

状态机的抽象

我们在做进程监控的时候,其目的就是为了在达到某些条件之后,让进程去进行某些我们需要的操作。这个功能抽象出来就是状态机。

God 中的进程监控就是使用的状态机的模型。在 God 源码中,状态机被抽象成了两个层次,分别为一般意义上的状态机和专门进行进程监控行为的状态机。一般意义上的状态机(Task)定义了状态机的状态设置、状态机状态转换行为、状态机开启与关闭和状态机运行机制等通用行为。而专门进行进程监控行为的状态机(Watch)继承自 Task,加入和重写了部分与进程监控功能相关的操作。

God 这样进行抽象我认为主要是为了让代码更加清晰易懂。首先,通过阅读源代码会发现,状态机 Task 的实现涉及了很多代码,包括 Driver、Metric、Condition 等,这些代码逻辑本身有一定的复杂度。而进程监控的状态机(Watch)除了需要状态机的相关操作之外,还要具有控制进程的相关操作。如果不做这种分层次的抽象,就会让进程监控的状态机的代码逻辑变得很庞大,不利于测试和维护。

行为接口的抽象

有哪些

God 中对行为接口的抽象有两个地方:

  1. Configurable module

在 Configurable module 中,其定义了 prepare、reset、valid?、complain 等通用的用于配置方面的接口,这些方法在模块里并没有实现。要使用这些接口的地方(例如 Contact、Behavior)只需要引入模块即可。

  1. Behavior

Behavior 这个类主要定义了两个方面的操作。一是定义一个通用的产生实例的方法(self.generate),如果你要产生一个在 Behaviors module 里面的某个类的实例(例如 A 的实例),只需要调用 Behavior.generate(:a) 就可以了。另一个方面 Behavior 定义了 Hook 接口(before_*after_*),所以所有继承自 Behavior 的类的实例都能够定制自己的 before_*after_* 方法。

有什么意义

说了他们具体抽象的内容之后,那么这两个抽象到底有什么意义呢?

  • 对其他调用的代码提供统一的调用接口和步骤

我们要明确这两个抽象都是对一类行为操作的抽象,这些行为除了进行本身的操作外,有的行为在操作前需要进行其他的相关操作,这些相关操作在这类行为中并不是都需要的。这就让外部调用很不方便,因为既然是同一类的行为,那么在外部看来我对你的使用应该是用同一样的方法,但是实际上我还要根据实际情况判断是否还要进行相关额外的操作。同样使用 Hook 也是一样的道理,对于有的行为会定义 Hook,而有的行为却没有。所以外部代码需要进行额外的判断。而通过上面的抽象之后,对于所有引入 Configurable module 的地方,对外部代码提供了统一的调用步骤(在进行操作前需要调用 prepare)和统一的方法。同样对于 Hook,外部代码只需要遵循 before_* —> * —> after_* 的调用流程就可以了。

  • 提供统一的创建接口,让调用者明白这些类具有同类型的功能。

Behavior 定义的 generate 方法是通过传入 Symbol 来产生 Behaviors module 空间中相应类的实例。而在该模块中,所有的类都具有同类型的功能。提供这种统一的创建接口,能够让调用者知道产生的实例具有同类型的功能。

  • 便于扩展

提供统一创建接口带来的另一个好处在于便于扩展。例如,Condition 类继承自 Behavior(Condition 类将 self.generate 方法进行了重写,用于产生 Contacts module 空间中的类的实例),通过 Condition 的 self.generate 方法能够很容易的创建各种条件的实例。为了更容易理解可扩展性,我们来看一下这个方法对 God 配置文件的影响。如下的 God 配置文件:

 God.watch do |w|
    w.name = "simple"
    w.start = "ruby /full/path/to/simple.rb"

    w.behavior(:clean_pid_file)

    w.transition(:init, { true => :up, false => :start }) do |on|
        on.condition(:process_running) do |c|
            c.running = true
        end
    end

    w.transition(:up, :start) do |on|
        on.condition(:process_exits)
    end
end

在这个配置文件中注意三个 Symbol( :clean_pid_file:process_running:process_exits),这三个 Symbol 最后会在 God 内部生成 CleanPidFileProcessRunningProcessExits 三个类的实例,这个转变就是通过 Condition 的 self.generate 方法来完成的。如果我们新加入了一个 Condition(例如 NewCondition),并且想在上述的进程监视任务中使用,那么我们只需要在模块中加入新的类(例如 NewCondition),然后在配置文件中直接通过 :new_condition 去产生该条件即可,而不用去修改其它地方的代码。

进程监控相关代码分析

通过前面讲的 God 的代码组织结构,我们知道 God 一共有 4 个子功能,分别是进程监控(Task)、通知(Contact)、使用 Kqueue/Netlink 机制(EventHandler)和给命令行提供接口(Socket)。

在这里我主要分析进程监控功能的运行机制(状态机的运行机制)以及 EventHandler 如何融入到进程监控中。而对于其他的功能和代码实现细节不会做太多的详述。

进程监控相关的主要的类如下图所示:

从图中我们可以看到,进程监控是主要由 Driver、Metric、Process、Behavior 几个类配合完成。

Behavior 的作用在前面提到了,其主要是用户产生 Behaviors module 空间中的类的实例,这些实例定义了在某些操作之前或之后所要进行的操作。例如 CleanPidFile 定义了 before_start 方法,其作用是在 start 操作之前要清理 pid 文件。

Process 主要功能在于提供调用在配置文件中配置的相关进程控制命令(start、restart、stop)的接口,根据配置设置进程的权限,关闭进程,给进程发送信号。也就是 Process 提供了根据我们配置去操作进程的接口。需要注意的是 Process 并没有提供去获取系统中进程真实信息的方法。而这部分功能是通过 System::Process 来完成的。

剩下的 Driver 和 Metric 是状态机实现的主要部分,也是下面会详细进行讲解的部分。

状态机的实现

首先我们先回顾一下状态机所需要的基本元素。我们以下图为例:

从图中我们可以直观的看到状态机所需要的基本元素有:状态(例如图中的 A、B、C、D 、E、F)、各个状态转换到其它状态的转换路线(例如图中的 A-a1-D, A-a2-B、C-c1-F、D-d1-C、D-d2-E)。

除了图中所体现的元素,状态机还需要有表示现在所处状态的变量和条件检查机制。所以要实现状态机,就要实现下面 4 个方面的要素:

  1. 拥有的状态
  2. 现在状态机所处的状态
  3. 从某一状态转换到下一状态的路线
  4. 条件检查机制

所以我们对照上面 4 个要素去在 God 中找到对应的实现。在 Task 中,你可以从源代码中快速的找到:valid_states 用于存放状态机所有的状态; state 属性保存着状态机的当前状态。

而上面的元素 3 和 4 就是分别由 Metric 和 Driver 来实现的。

转换路线的表示

实现方式

要表示出 “从某一状态转换到下一状态的路线”,有三个方面的元素需要表示出来:1. 起始状态;2. 转换条件;3. 目的状态。

在 Metric 中表示出了转换条件(conditions)和目的状态(destination)。也就是下图中红色方框的部分,我们将 Metric 表示的这种关系用符号 c — S 表示,c 表示转换的条件,S 表示目的状态。下图中红色方框就可以写为 a1 — B

将 “起始状态” 与 Metric 表示的两个元素联系起来是通过 Task 中的名为 metrics 的 hash 来完成的。下面是某一个状态机的 metrics 中的内容:


# 某个状态机中的 metrics 的内容
self.metrics = {nil => [metric1, metric2], :unmonitored => [metric3], 
                          :stop => [metric4], :start => [metric5]}

从上面的事例中我们可以看出,metrics 中的 key 表示起始状态,而其 value 是一个保存 Metric 实例的数组,结合上面所讲的 Metric 的含义。那么我们可以知道 metrics 中一个 “键—值” 对表示了 “从某一个起始状态转换到其他状态的所有路线”。我们还是举个例子,如图所示: 在上图中我们用分别用 :A:B:C 表示 A、B、C 三个状态,用metric_a1metric_a2 表示 a1— Ba2 — C 。那么上图中的状态机的 metrics 的内容如下:

self.metrics = {:A => [metric_a1, metric_a2], :B => [],  :C => []}

通过一个 hash 和 Metric,就实现了状态机的第 3 个要素。

实现技巧

在 Metric 实现的时候,其使用了两个小的技巧。

  • 技巧一

有时候状态机转换的时候会出现下图所示的这种情况:

当在 A 状态时,如果测试满足条件 a1 的时候,状态转换为 B。相反如果测试条件不满足 a1,也就是说满足与 a1 相反的条件 a1' 的时候,其状态转换为 C。对于这种情况,利用上面我们谈到的实现,就需要创建两个 Metric 的实例 metric_a1、metric_a1', 分别表示 a1 — Ba1‘ — C 的状态。

但是 God 中对于这种情况有一个更巧妙的实现,Metric 中的 destination 属性是一个形式为 { true => :state1, false => :state2}的 hash。其含义表示,当 Metric 实例中的条件有测试为 true 的时候,状态转换为 state1,而当测试条件不通过的时候,状态转换为 state2。所以对于上图中的情况,实际上只需要一个 Metric 实例,将 a1 作为条件添加到实例中的,然后将 destination 设置为 { true => :B, false => :C} 就可以了。

  • 技巧二

所有从某一个起始状态到同一个目的状态的条件,既可以放在一个 Metric 实例中,也可以放在多个 Metric 实例中。

Metric 的 conditions 属性是一个数组,可以用于存放多个条件。从同一个起始状态到同一个目的状态的路线放入同一个 Metric 实例中可以说是很自然的。但是由于每一个 Metric 实例都是独一无二的。所以从同一个起始状态到同一个目的状态的路线也可以放入不同的 Metric 实例中,然后将这些实例放入 Task 的 metrics 的属性当中。

God 为什么这么实现呢? 我觉得其好处主要在于能够增加用于写配置文件的自由程度。例如下面两个配置文件:

#配置文件 1
God.watch do |w|
  w.name = "simple"
  w.start = "ruby /full/path/to/simple.rb"
  w.keepalive

   w.transition(:start , :up) do |on|
    on.condition(:process_running) do |c|
      c.running = true
    end
   end

  w.transition(:up, :restart) do |on|
    on.condition(:memory_usage) do |c|
      c.interval = 20
      c.above = 50.megabytes
      c.times = [3, 5]
    end

    on.condition(:cpu_usage) do |c|
      c.interval = 10
      c.above = 10.percent
      c.times = [3, 5]
    end
  end
end
#配置文件 2
God.watch do |w|
  w.name = "simple"
  w.start = "ruby /full/path/to/simple.rb"
  w.keepalive

   w.transition(:start , :up) do |on|
    on.condition(:process_running) do |c|
      c.running = true
    end
   end

  w.transition(:up, :restart) do |on|
    on.condition(:memory_usage) do |c|
      c.interval = 20
      c.above = 50.megabytes
      c.times = [3, 5]
    end
  end

  w.transition(:up, :restart) do |on|
    on.condition(:cpu_usage) do |c|
      c.interval = 10
      c.above = 10.percent
      c.times = [3, 5]
    end
  end
end

上面两个配置文件所表达的意义是一样的,唯一不同的地方就在于从 up 状态转换到 restart 状态的两个条件被放到了不同的 Metric 实例中而已。用户也不必拘泥于只能写第一种形式的配置文件。

(对于 God 这么做我只能分析出这么一个好处,但是就内心来讲,感觉其实并没有什么实质的用途。)

条件检查机制实现

状态机的前 3 个要素表示完之后,关于状态机的描述已经很清楚了,剩下的就是引入 “检查机制” 来让状态机运行起来。

God 的检查机制思路很简单。就是设置一个队列,将所有以当前状态为状态转换起始的条件放入该队列;然后根据设置的时间间隔,循环取出队列头的条件进行测试;如果条件测试成功,那么就清除现有队列中所有的条件,然后转换到相应的目的状态,并将所有以新状态为状态转换起始的条件放入队列,重复上面过程。如果条件测试不成功,那么将取出的条件再加入队列的尾部,然后取出队列头的条件,重复上面的步骤。整个过程可以用下面的图进行表示: 在了解实现的思路后,我们来看一下具体的代码实现。

在 God 中,检查机制的队列是由 Driver 进行维护的。Driver 实例中保存着一个 DriverEventQueue 的一个队列,并且开启了一个线程来不断的从队列中取出队列头部的元素,取出元素之后 Driver 本身并不再做多余的工作。也就是说 Driver 的实例只是完成下面红色方框中的功能:

而绿色方框的功能则交给了 Task 实例来完成,准确的来说是交给 Task 实例的 handle_poll 来完成的。所以整个检查机制的实现是: Driver 不断从自己维护的队列中取出条件,然后调用 Task 实例的 handle_poll 方法来完成测试和与测试结果有关的其他操作。

至此,状态机的功能已经完整的实现了。

EventHandler

通过上面实现的检查机制,所有状态转换条件都需要状态机自己主动测试,这样的实现最大的问题就是实时性不够。对于有的条件,我们想要在条件满足后,立即进行状态的改变(例如:进程退出事件。因为进程退出后,其他很多条件测试已经没有意义了)。所以需要利用事件通知机制,在某些事件发生后立即通知状态机。在 God 中,通过 EventHandler 来使用系统提供的 Kqueue (BSD 和 Darwin 中的机制 ) 或者 Netlink (Linux 中进程间通信的机制) 来解决这一问题。

在继续详细讲述 EventHandler 的具体实现之前,我们再来回顾一下前面的分层结构,我们看到 EventHandler 是作为一个 God 独立的子功能来实现的,而并不是作为为了实现状态机(Task)的功能而增加的子功能。

在重新回顾了 EventHandler 在 God 中的地位之后,我们来看一下 EventHandler 的实现细节。

对外接口

EventHandler 对外提供的接口主要有:

  1. 控制自生生命周期的 self.loadself.startself.stop
  2. 用于注册和注销事件的 self.registerself.deregister

EventHandler 对外部使用者来说最主要的接口就是 self.register,这个接口接受 3 个参数(pid, event, block)。pid 就是我们所要监控的进程的 pid 号,event 就是我们所要监视的事件,block 是一个代码块,这个代码块会在事件发生后被调用。

所以从对外部提供的接口来看,EventHandler 只是简单的对系统的事件通知机制做了一个包装,让使用者能够注册事件,然后在注册的事件发生后调用使用者设置的相关操作。

内部实现细节

  • 主要属性

类变量 @@handler:用于保存 KQueueHandler 或者 NetlinkHandler 类。这两个类负责具体实现使用系统事件机制。

类变量 @@actions:它是一个 hash,用于保存我们所注册与每一个 pid 对应的 event 和与 event 对应的 block.

  • 具体实现

NetlinkHandler 和 KQueueHandler 作为使用系统事件通知机制的具体实现,这两个类主要提供了 register_processhandle_events 两个方法,它们分别用于注册事件和当事件触发时调用用户传入的 block。在 EventHandler 中,其开启一个线程,循环调用 @@handlerhandle_events 方法,接受来自系统的事件通知。

与状态机结合

从上面 EventHandler 的实现中,我们可以看到,EventHandler 本身只是提供了使用系统事件通知机制的功能,并没有直接与状态机结合。要与状态机结合,唯一的方法就是通过在注册事件时传入的 block 。但是如果直接在这里的 block 中进行状态改变那就肯定会出现问题,因为这样就会有两个线程同时具有改变状态机的权限,而这两个线程在使用状态机资源上却没有实现互斥。

在 God 的实现中,状态机的改变都是交给 Driver 实例中所开启的线程。而实现这个目的最简单的方法就是将 EventHandler 中注册的事件触发后所需要做的操作加入到 Driver 队列的队列头部,然后 Driver 根据取出元素的类型去决定让状态机执行测试还是直接进行相关操作。所以在 Driver 的队列中存在两种类型的元素:一种是 DriverEvent,表示 condition;另一种是 DriverOperation,表示状态机所要做的相关的操作。而要添加 DriverOperation 事件,就需要调用 Driver 提供的 message 方法。

所以 EventHandler 与状态机结合的方法就是在注册事件时提供的 block 中调用 Driver 的 message 方法,以此来通知状态机要做的相关操作。

对 God 进行扩展

God 的代码结构非常清晰,同时也非常利于扩展。比如我自己就对 God 进行了扩展,实现了对文件的监视。我在这里简单讲述一下思路,在明白 God 源码之后,进行功能扩展是非常容易的。

实现状态机

首先,由于对文件的监视本身也是一个状态机,所以继承 Task 获得状态机所具有的一般的操作。

然后,确定状态机具有的状态。例如在我的实现中,定义了 :unmonitor,:move_to 和 :delete 三个状态。其中 :move_to 的状态表示将文件移动到某个我们所配置的位置,:delete 表示将文件删除。

最后,在状态机中加入与操作 file 相关方法。这里可以借鉴 Watch 的实现,将 file 相关的操作,放入单独的类中去,然后将这个类的实例作为状态机的属性。

加入条件

我想要监视文件大小的变化。所以在 Conditions module 中加入 FileSize 的条件,这里没有用到 EventHandler 的机制,所以 FileSize 继承自 PollCondition,并实现相关的接口即可。

在 God 中提供相关的接口

为了不破坏原有的 God 的功能,你需要在 God module 中提供额外的控制信息和接口。

  1. 需要在 God 中提供额外的变量用于保存自己的状态机。
  2. 在 God 中提供控制状态机生命周期的代码。例如, self.start 中提供代码启动自己的状态机;在 terminate 方法中提供关闭状态机的代码。
  3. 在 God 中提供接口使用于能够使用。

我的扩展结果

  • 配置文件

我的配置文件如下:

God.file_watch do |w|
  w.name = "file"
  w.file_path = "/path/to/temp/file"

  w.move_to = "/path/to/temp/c_success"

  w.interval = 5.seconds

  w.transition(:init, :move_to) do |on|
    on.condition(:file_size) do |c|
      c.above = 15
    end
  end

  w.transition(:move_to, :delete) do |on|
    on.condition(:file_size) do |c|
      c.above = 30
    end
  end
end

这个配置文件的意思是: 监视路径为 "/path/to/temp/file" 的文件,当文件的大小大于 15 byte 的时候,将文件重命名为 "/path/to/temp/c_success" 。当文件大小大于 30 byte 的时候,将该文件删除。

  • 运行结果

我的收获

God 作为第一个我读的 Ruby 的源码,我从它这里收获很多。

God 代码量虽然不算大,但是它基本上涵盖了 Ruby 语言中的全部知识,从基本的 Ruby 语法到 Ruby 元编程技术再到 C 的扩展都有实用。而且,God 的代码逻辑非常清晰,可以学习不同模块之间如何在低耦合的情况下进行交互。还有 God 代码的组织上也一目了然,同类型功能的类都放在同样的模块下面。从阅读中还能学到写代码时各种各样的小技巧。

作为一个新人,我强烈推荐其他的同伴也去阅读 God 源码。

下面是我觉得阅读 God 源码所需要的知识:

  1. Ruby 基本语法
  2. 《Ruby 元编程(第二版)》前 5 章的内容

有了上面的知识基础,读下来肯定是没有问题的。 当然你可能还需要下面的资料:

  1. Ruby 的 C 扩展的简单入门
  2. Kqueue 的讲解 (其实这个我还是没有看懂)
  3. [DRB 相关知识]

后记

作为很少写东西来说的我,写这篇文章还是很费力。其实最主要的问题在于不知道什么该讲什么不用讲。最开始的时候,我其实想通过一个简单的配置文件来讲述 God 的运行流程和代码结构的,但是通过方式讲解,要涉及的细节太多,写起来也比较烦躁。到最后,我以 “我最想跟大家分享 “为立足点来写的。立足于这一点,我发现自己写起来也比较开心,文章的结构思路也相对较为清晰。

不知道大家对于写技术文有哪些自己的观点呢?

研究的很透彻啊,👍 ,我也实现过一个简单的通过检测 restart.txt 来自动重启进程的小扩展

#1 楼 @markgeek 谢谢。其实我这里扩展并没有太大的作用,只是为了扩展而扩展而已

:plus1: 我也是去年接触 ruby,3 三月正式实习,怎么感觉和楼主有差距呢。

远程实习很 nice,怎么找到 的?

#3 楼 @hw676018683 我这个代码读的比较细,所以能写的东西多。你去试试也一样的

#4 楼 @hemengzhi88 很偶然的机会,开始上课思客的课程,然后我说我来实习,然后就 ok 了。

非常棒!

很认真地看完了这篇文章后,对于同是 Ruby 新人的我: 1、注册了一个帐号来回复 2、决定下载 God 的源代码解析一下

感谢 redemption 的分享!

我也要搞起看看 ruby 社区太棒了

需要 登录 后方可回复, 如果你还没有账号请 注册新账号