程序人生

写优雅的程序,做优雅的人

实例探索 Git 的分支 branch 和标签 tag

| Comments

上一篇讲到 Git 的对象模型,通过它已经能够深入了解 Git 背后如何组织和储存数据,但是总觉得还是缺一点什么,那就是 Git 的必杀技分支和对象模型的关系,接下来就通过一些实例来弄清楚 Git 的分支是如何运作的。

在 Git 对象模型中讲到,每一次 Git 提交,都会生成一个 commit 对象,这个 commit 对象有一个 SHA1 key,例如 4240c635214402420f4fd907c378f0d01a5b5d8e。简单的说,Git 的分支就是一个引用 reference,这个引用有一个名称(分支的名称),然后指向一个具体的 commit 对象(通过 commit 对象的 key),就是这么简单。

分支的数据保存在那里呢?都在目录 .git/refs 中,通常这个目录下有三个子目录,分别是 heads,tags 和 remotes。 我们可以看到 .git/refs/heads 下有一些文件,每一个文件表示一个分支,文件的名称就是分支的名称,文件的内容就是 commit 对象的 key。

例如,dig-git 项目里:

1
2
3
4
cat .git/refs/heads/master
058ca0a55e60eae77dac9818f915dad581b5465b
cat .git/refs/heads/test1
0c5ca6c9e46a1e6cfe04eaea7cd5db3223ba6693

是不是很简单,知道了这个原理,我们甚至可以绕开 Git 命令,直接通过文件操作生成一个 branch。 例如:

1
echo "3c31e0a28cfc7f022984d6f8250a1c5c0670f120" >> .git/refs/heads/test2

通过上面的命令,在 .git/refs/heads 目录下生成一个 test2 文件,文件内容是一个 commit 对象的 key,这样就生成了一个 test2 分支。

标签 tag 和分支 branch 的本质是一样的,都是一个指向 commit 对象的引用,只不过标签 tag 的文件放在 .git/refs/tags 目录下而已。

实例探索 Git 对象模型

| Comments

一个纯研究演示 Git 对象模型的项目,项目在 https://github.com/xiewenwei/dig-git,总共有 5 次 commit(包括一次 merge),都是很简单的内容,我们可以仔细观察一下每一 commit 之后 Git 对象模型图,以此分析 Git 存储的原理。

    1. 首次提交,提交一个简单的文件 a.txt ,commit 之后的图如下:

如图所示,生成了 3 个对象,一个 commit 对象,一个 tree 对象,一个 blob 对象。图上蓝底是 commit 对象,灰底的是 tree 对象,白底的是 blob 对象,每个对象节点的标题是对象的 key (SHA 摘要)缩略表示。 对于 commit 对象,tree 内容表示这个 commit 对应根目录的 tree 对象,parent 表示父 commit 节点,通常commit 只有一个父节点,也可能没有(首次提交时 parent 为空),也可能有多个(合并节点),commit 对象还保存了 commit message 等信息。 对于 tree 对象,里面的内容包含了文件名,文件对应的 blob 对象的 key,或者是目录名和目录对应 tree 对象的 key。 对于 blob 对象,表示一个实际文件对象的内容,但不包括文件名,文件名是在 tree 对象里存的。

这个图怎么得到的呢?主要是两个命令: * 通过 git log 命令获取最新 commit 的 key * 通过 git cat-file -p <object key> 获取 key 对应 object 的内容,根据 object 里的内容,继续探索,就可以访问到所有关联 object.

    1. 第 2 次提交,修改了 a.txt 文件:

因为 a.txt 文件已经修改,生成了一个新的 blob 对象,tree 对象和 commit 对象。如图所示,commit 对象之间是有关联的,新提交的 commit 对象的 parent 是上一次提交的 commit 对象。

    1. 第 3 次提交,这次已稍微复杂一点,增加一个新文件 b.txt ,一个新目录 lib ,lib 里增加一个文件 c.txt

如图所示,目录是有一个 tree 对象表示的,里面的内容指明了目录包含的文件或子目录。

    1. 第 4 次提交,这次弄出一个新的分支 test1,并且在新分支中做了一次 commit

0c5ca 对应的 commit 对象就是生成的分支 test1 中的。分支在 Git 中是一个非常轻量化的操作,建立分支甚至都不增加新的对象。

    1. 第 5 次提交,这次涉及到一个合并操作,图已经变得比较复杂了

def18 就是合并后的 commit 对象。合并生成了一个新的commit ,这个 commit 的 parent 有两个,指向合并的两个原分支对应的 commit 上。

抱歉没有写得很详细,恐怕需要自己参照例子试试一看看,搞明白这些图,也就能搞明白整个 Git 对象模型机制了。

使用 capistrano 部署到内部服务器

| Comments

capistrano 是使用方便,功能强大的自动化部署工具,它已经成为 Ruby 部署事实上的标准,薄荷项目自动化部署一直使用 capistrano。以前薄荷的服务器基本上是物理机,这些物理机都直接连接外网,最近租用了一些了云主机,为了更好的安全性,同时也为了节约成本,绝大大部分云主机并没有连接外网,只能通过一台连接外网的中转机连接。我们需要把应用部署到这些内部服务器上,最初我们使用了在中转机进行端口转发的方式,最近使用 capistrano deploy via gateway(姑且把它称为网关中转)方式,十分方便。

服务器部署示意图如下所示:

服务器部署示意图

端口转发方式

端口转发方式要对中转服务器端口转发做设置,在 iptables 中配置例子如下:

1
-A PREROUTING -p tcp -m tcp --dport 10222 -j DNAT --to-destination 192.168.1.102:22

端口转发实质上和 capistrano 没有直接关系,是在网络层面对中转服务器和内网实际部署服务器做了设置,让 capistrano 通过一个特定的端口与内网服务器通信,在 capistrano 看起来,内网服务器和中转服务器是一样的,只是端口不同而已。

网关中转方式

网关中转方式下,部署客户机首先和中转服务器建立连接,中间服务器再和内网服务器建立连接,部署工作站和内网服务器通过这两个连接通信。网关中转方式通过 capistrano 设置 deploy via gateway 选项完成,不需要对中转服务器做转发设置。

需要注意的是,使用网关中转方式时,capistrano 2.x 和 capistrano 3.x 差异很大,两种并不兼容。

capistrano 2.x 设置很简单,只要设置 gateway 选项就行了,如下所示:

1
2
  # 实际使用中要把 <user> 和 <gateway host> 替换为真实值
  set :gateway, "<user>@<gateway host>"

capistrano 2.x 的设置在 capistrano 3.x 下不能工作,原因是 cap3 对网络连接做了非常大得重构,原来一些特性使用接口有变化。 capistrano 3.x 设置如下:

1
2
3
4
5
  require 'net/ssh/proxy/command'

  # 实际使用中要把 <user> 和 <gateway host> 替换为真实值
  set :ssh_options, proxy:
    Net::SSH::Proxy::Command.new('ssh <user>@<gateway host> -W %h:%p')

总结:capistrano 可以通过简单的设置完成向内网服务器部署应用,有端口转发和网关中转两种方式,推荐使用网关中转方式。

Redis 使用模式之一:计数器

| Comments

Redis 是目前 NoSQL 领域的当红炸子鸡,它象一把瑞士军刀,小巧、锋利、实用,特别适合解决一些使用传统关系数据库难以解决的问题。打算写一系列 Redis 使用模式的文章,深入总结介绍 Redis 常见的使用模式,以供大家参考。

常见汇总计数器

汇总计数是系统常见功能,比如网站通常需要统计注册用户数,网站总浏览次数等等。 使用 Redis 提供的基本数据类型就能实现汇总计数器,通过 incr 命令实现增加操作。

比如注册用户数,基本操作命令如下:

1
2
3
4
  # 获取注册用户数
  get total_users
  # 注册用户数增加一位
  incr total_users

按时间汇总的计数器

通常计数还要按时间统计,比如注册用户数需要按日统计,处理方法比较简单,把日期带入计数器 key 就可以。

还是注册用户计数的例子,基本操作命令如下:

1
2
3
4
5
6
7
  # 假定操作 2014-07-06 数据
  # 获取注册用户数
  get total_users:2014-07-06
  # 2014-07-06 注册用户数增加一位
  incr total_users:2014-07-06
  # 设置 48 小时过期时间 172800 = 48 * 60 * 60
  expire total_users:2014-07-06 172800

为计数器设置一个 48 小时的过期时间是为了节省计数器占用空间,毕竟 redis 是内存数据库,可以在过期前执行一个任务把计数器存入关系数据库。

速度控制

速度控制也是 Redis 一种常见的计数用途,比如有一个 API 服务,希望控制每一个 IP 每秒请求数不超过 10 次,可以用 IP 和 时间秒作为 key 设置一个计数器,实现控制,伪代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  # 每秒最大请求数
  MAX_REQUESTS_PER_SECOND = 10

  # 检查 ip 请求限制
  # @param ip
  # @raise 超过限制,抛出 RuntimeError 异常

  def check_request_limitation_for_ip(ip)
    time_tick = Time.now.to_i
    key = "#{ip}:#{time_tick}"
    num = $redis.get(key).to_i
    if num > MAX_REQUEST_PER_SECOND
      raise 'too many requests'
    else
      $redis.incr(key)
      $redis.expire(key, 10)
    end
  end

使用 Hash 数据类型维护大量计数器

有时候需要维护大量计数器,比如每一个论坛主题的查看数,比如每一个用户访问页面次数,因为论坛主题和用户基数可能很大,直接基于论坛主题或用户 ID 生成计数器的话,占用 Redis 资源还是相当可观的,这时可以用 Hash 数据类型压缩所需资源。

比如,对应论坛主题查看计数,可以由模式

1
2
  key: topic:<topic_id>:views
  value: view count (integer)

转换为模式:

1
2
3
4
  key: topic:views
  value: hash
    hash key: <topic_id>
    hash value: view count (integer)

总结:利用 Redis 实现计数器,可以简单高效实现各种计数功能。

MongoDB 中建索引注意事项

| Comments

上周在 ruby-china 上发了帖子《MongoDB 那些坑》,反映相当热烈,许多回复很有见地,其中一位童鞋深入的提到 MongoDB 建索引方法的问题,引发我更深入的了解了 MongoDB 建索引的方法和一些注意事项。

在 《MongoDB 那些坑》中提到,在前台直接运行建立索引命令的话,将造成整个数据库阻塞,因此索引建议使用 background 的方式建立。但是这也会带来一定的问题,在 2.6 版本之前,在 secondary server 中即使使用 background 方式建立索引,secondary 还是会以 foreground 方式建立索引,它导致 secondary 同样引发数据库阻塞问题。2.6 版本修复了这个 Bug,2.6 版之后使用 background 方式建立索引时,真正转向后台运行了。

为了尽量降低建立索引对 MongoDB Server 的影响,有一种方法是把 MongoDB Server 转换成 standalone 模式后建立。具体做法如下:

  1. 首先把 secondary server 停止,在取消 --replSet 参数,并且更改 MongoDB port 之后重新启动 MongoDB,这时候 MongoDB 将进入 standalone 模式;

  2. 在 standalone 模式下运行命令 ensureIndex 建立索引,建议使用 foreground 方式运行;

  3. 建立索引完毕之后关闭 secondary server 按正常方式启动;

  4. 根据上述 1~3 的步骤轮流为 secondary 建立索引,最后把 primary server 临时转换为 secondary server,同样按 1~3 的方法建立索引,再把其转换为 primary server。

这种方式还是比较麻烦的,但可以把建立索引操作对 MongoDB 的影响降到最低,在有些情况下还是值得做的。

参考资料: build-indexes-on-replica-sets

【上海】薄荷科技长期寻找靠谱的 Ruby, Web 前端,iOS 和 Android 工程师

| Comments

上海薄荷科技寻找靠谱的高级 Web 前端工程师

  • 工作地点:上海浦东新区张江高科技园
  • 职位:Ruby, Web 前端,iOS 和 Android 工程师(长期)
  • 职责:薄荷 App,薄荷网站和微信平台 Web 程序设计开发,架构设计,代码重构,性能优化。

我们是谁

薄荷是中国领先移动健康公司,主要产品“薄荷” 是中国最受欢迎的健康健美类 App 之一,用户已近千万,长期位于 App Store 健康健美榜前列,长期位于各大 Android 应用市场健康或生活类应用前列。

我叫谢文威,是薄荷联合创始人,我在 2006 年底和大学同学、上下铺的兄弟马海华一起建立了薄荷。在创立薄荷之前,我曾经做过多年的程序员,DBA,数据挖掘工程师和项目经理,我在薄荷主要负责技 术。薄荷是我第一次创业,走过很多弯路,可谓屡败屡战,所幸我们顽强的生存下来,现在还算有不错的进展。

薄荷一直专注女性健康生活领域,我们十分信奉法国一位哲学家卢梭的一条格言 “在所有的人类知识中,最重要而又最缺乏的是关于人的知识” ,我们相信随着大数据时代,数据化自我时代的到来,基于数据应用改善人的健康和美丽方向一定大有可为。

寻找什么样的人

我们希望找到靠谱各领域工程师 ,一些要求如下:

  • 1 年以上工作经验;
  • 精通所在领域相关技术,追求极致用户体验,
  • 编程基础良好,追求优雅的代码;
  • 热爱技术开发,有极客精神,参与过开源项目或常写技术博客加分哦!

我们注重能力包括:

  • 解决问题能力。自信,积极主动,做事有条理,目标导向。
  • 学习能力。乐于学习,善于总结分享,渴望每天都在进步。
  • 团队协作能力。诚实,坦率,有责任心,信任、尊重、包容。

我们能为你提供什么

  • 发挥你能力的舞台

薄荷的技术系统正处于从 数百万用户量到数千万量级 演进过程中,这里充满了机会和挑战,如果你是技术大牛,或者渴望快速成长为技术大牛,这里有供你发挥的舞台。如果想未来成为产品经理,或者自己创业,这里也将为你打下坚实的基础。

  • 优雅舒适的环境

为了让你写出优雅的代码,我们当然为你提供优雅的工作环境。相比其它互联网公司,薄荷一个特点是女员工比例很高,男女比例是 1 比 2,我们有美女程序员,美女设计师,美女咨询师和美女产品经理 … 因为我们从事的是女性美丽相关的业务,我们的口号是:与美女一起共事,为美女服务!

  • 超出你预期的回报

年薪 10~25 万,上不封顶,取决于你的能力和业绩。目前公司处于快速发展阶段,公司已全面盈利,并且已有顶尖美元风投基金加入(晨兴,SIG,DCM和高通),未来充满无限想象。我们将把你视为伙伴,希望通过一起努力,能够提供远超你预期的回报。

联系方式

如果您对我们的工作岗位感兴趣,请把您的简历或情况发邮件到vincent(at)boohee.com,最好能充分展示您的能力,谢谢!

下面是我们目前的办公环境

薄荷的办公环境

MongoDB 那些坑

| Comments

MongoDB 是目前炙手可热的 NoSQL 文档型数据库,它提供的一些特性很棒:如自动 failover 机制,自动 sharding,无模式 schemaless,大部分情况下性能也很棒。但是薄荷在深入使用 MongoDB 过程中,遇到了不少问题,下面总结几个我们遇到的坑。特别申明:我们目前用的 MongoDB 版本是 2.4.10,曾经升级到 MongoDB 2.6.0 版本,问题依然存在,又回退到 2.4.10 版本。

MongoDB 数据库级锁

坑爹指数:5星(最高5星)

MongoDB的锁机制和一般关系数据库如 MySQL(InnoDB), Oracle 有很大的差异,InnoDB 和 Oracle 能提供行级粒度锁,而 MongoDB 只能提供 库级粒度锁,这意味着当 MongoDB 一个写锁处于占用状态时,其它的读写操作都得干等。

初看起来库级锁在大并发环境下有严重的问题,但是 MongoDB 依然能够保持大并发量和高性能,这是因为 MongoDB 的锁粒度虽然很粗放,但是在锁处理机制和关系数据库锁有很大差异,主要表现在:

  • MongoDB 没有完整事务支持,操作原子性只到单个 document 级别,所以通常操作粒度比较小;
  • MongoDB 锁实际占用时间是内存数据计算和变更时间,通常很快;
  • MongoDB 锁有一种临时放弃机制,当出现需要等待慢速 IO 读写数据时,可以先临时放弃,等 IO 完成之后再重新获取锁。

通常不出问题不等于没有问题,如果数据操作不当,依然会导致长时间占用写锁,比如下面提到的前台建索引操作,当出现这种情况的时候,整个数据库就处于完全阻塞状态,无法进行任何读写操作,情况十分严重。

解决问题的方法,尽量避免长时间占用写锁操作,如果有一些集合操作实在难以避免,可以考虑把这个集合放到一个单独的 MongoDB 库里,因为 MongoDB 不同库锁是相互隔离的,分离集合可以避免某一个集合操作引发全局阻塞问题。

建索引导致数据库阻塞

坑爹指数:3星

上面提到了 MongoDB 库级锁的问题,建索引就是一个容易引起长时间写锁的问题,MongoDB 在前台建索引时需要占用一个写锁(而且不会临时放弃),如果集合的数据量很大,建索引通常要花比较长时间,特别容易引起问题。

解决的方法很简单,MongoDB 提供了两种建索引的访问,一种是 background 方式,不需要长时间占用写锁,另一种是非 background 方式,需要长时间占用锁。使用 background 方式就可以解决问题。 例如,为超大表 posts 建立索引, 千万不用使用

1
db.posts.ensureIndex({user_id: 1})

而应该使用

1
db.posts.ensureIndex({user_id: 1}, {background: 1})

不合理使用嵌入 embed document

坑爹指数:5星

embed document 是 MongoDB 相比关系数据库差异明显的一个地方,可以在某一个 document 中嵌入其它子 document,这样可以在父子 document 保持在单一 collection 中,检索修改比较方便。

比如薄荷的应用情景中有一个 Group document,用户申请加入 Group 建模为 GroupRequest document,我们最初的时候使用 embed 方式把 GroupRequest 放置到 Group 中。 Ruby 代码如下所示(使用了 Mongoid ORM):

1
2
3
4
5
6
7
8
9
10
11
12
13
class Group
  include Mongoid::Document
  ...
  embeds_many :group_requests
  ...
end

class GroupRequest
  include Mongoid::Document
  ...
  embedded_in :group
  ...
end

这个使用方式让我们掉到坑里了,差点就爬不出来,它导致有接近两周的时间系统问题,高峰时段常有几分钟的系统卡顿,最严重一次甚至引起 MongoDB 宕机。

仔细分析后,发现某些活跃的 Group 的 group_requests 增加(当有新申请时)和更改(当通过或拒绝用户申请时)异常频繁,而这些操作经常长时间占用写锁,导致整个数据库阻塞。原因是当有增加 group_request 操作时,Group 预分配的空间不够,需要重新分配空间(内存和硬盘都需要),耗时较长,另外 Group 上建的索引很多,移动 Group 位置导致大量索引更新操作也很耗时,综合起来引起了长时间占用锁问题。

解决问题的方法,说起来也简单,就是把 embed 关联更改成的普通外键关联,就是类似关系数据库的做法,这样 group_request 增加或修改都只发生在 GroupRequest 上,简单快速,避免长时间占用写锁问题。当关联对象的数据不固定或者经常发生变化时,一定要避免使用 embed 关联,不然会死的很惨。

不合理使用 Array 字段

坑爹指数:4星

MongoDB 的 Array 字段是比较独特的一个特性,它可以在单个 document 里存储一些简单的一对多关系。

薄荷有一个应用情景使用遇到严重的性能问题,直接上代码如下所示:

1
2
3
4
5
6
class User
  include Mongoid::Document
  ...
  field :follower_user_ids, type: Array, default: []
  ...
end

User 中通过一个 Array 类型字段 follower_user_ids 保存用户关注的人的 id,用户关注的人从 10个到 3000 个不等,变化是比较频繁的,和上面 embed 引发的问题类似,频繁的 follower_user_ids 增加修改操作导致大量长时间数据库写锁,从而引发 MongoDB 数据库性能急剧下降。

解决问题的方法:我们把 follower_user_ids 转移到了内存数据库 redis 中,避免了频繁更改 MongoDB 中的 User, 从而彻底解决问题。如果不使用 redis,也可以建立一个 UserFollower 集合,使用外键形式关联。

先列举上面几个坑吧,都是害人不浅的陷阱,使用 MongoDB 过程一定要多加注意,避免掉到坑里。

参考资料: * 1.MongoDB 锁机制详解 * 2.MongoDB 建立索引操作文档

保护 Redis 的数据

| Comments

Redis 是非常流行的 key-value 类型的 NOSQL 数据库,它的 value 数据类型丰富多样,适合解决很多对关系数据库来说棘手的问题。Redis 是内存数据库,也就是说它把所有的数据都放在内存中。众所周知,当前机器突然断电或者系统异常崩溃时,内存的数据会丢失的,所以如果把系统的关键数据放在 Redis 中必须做好保护措施。

Redis 提供了两种持久化数据机制:

一种是 RDB 机制。这种方式是把当前整个 Redis 内存数据快照写到磁盘上。优点是比较简单,恢复快速。缺点是代价比较昂贵,尤其当数据量很大时,会严重影响到 Redis(照成 Redis 停顿),而且会极度消耗磁盘 IO。

另一种是 AppendOnly 机制。这种方式类似于 log,把数据操作命令全部存入 log 文件。优点是每段时间写入数据不是很大,可以设置很短的写入时间间隔(比如1秒钟)。缺点是 log 文件可能会远大于数据文件,通常会是数据文件大小的 3~5 倍,而且恢复的时间要远大于 RDB 方式的恢复时间。

这两种机制各有优劣,不过它们是可以结合起来使用的。在实际使用过程,通常还结合 Redis 的 slave 功能,做到对 Redis 影响更小,保护更充分。具体的做法是对 Redis 建立 master 和 slave,在 master 上根本不使用任何持久化机制,只在 slave 上建立结合 RDB 和 AppendOnly 的持久化机制。

分割 Redis 实例

| Comments

薄荷 App 上的伙伴功能大量使用了内存数据库 Redis,随着数据量的快速增长,Redis 膨胀得很快,已经接近 12 GB规模,这些数据全部放在单个 Redis 实例中。单个巨大 Redis 实例有如下几个坏处:

  1. 首先,需要一台内存很大的机器。Redis 是内存数据库,它需要把所有需求全部放在内存中,需要为之装下 12 GB的 Redis 实例,至少需要 12 GB 内存大小的机器,考虑的预留增长空间,一般需要 12 * 1.5 约 18 GB 内存。 另外还有一个考虑的因素是,Redis 进行硬盘数据存储时,fork 进程需要消耗同样大小的内存,因此一个 12GB 的 redis 实例需要 32 GB左右的内存比较合适,这对机器提出了很高的要求,常常难以满足。

  2. 然后,Redis 容易成为性能瓶颈。Redis 的并发模型是单进程单线程,它不能充分利用多核 CPU,在请求数很高,或者某一些请求处理比较慢时(比如一些大的数据排序),可能会成为系统的性能瓶颈。有方法可以缓解甚至这个问题,就是建立多个 Redis 实例,通过多个 Redis 连接来实现。

  3. 另外,单个巨大的 Redis 实例也会增加数据管理难度,因为这么大的数据量,无论是复制,备份操作都比较慢,容易对线上系统造成冲击。

因此,十分有必要把单个巨大的 Redis 实例分割成多个小的 Redis 实例。

使用 Redis 的复制机制,可以在线平滑处理 Redis 实例分割,几乎不会对系统有很大的影响。

分割的具体操作思路如下:

  1. 首先,规划 Redis 分割策略,通常是基于业务划分,比如薄荷伙伴是基于业务分成 timeline, user_relationship, other 3个 Redis 实例。规划好之后,需要根据规划结果对应用程序中 Redis 程序代码做修改,通常是有一个统一的 Redis 链接修改为多个 Redis 连接,不同业务使用不同的连接。

  2. 然后,通过 Redis 复制功能建立多个 Redis 副本,让不同 Redis 连接使用不同的 Redis 副本,在 Redis 副本中删除多余的数据。批量删除某个模式的 keys,可以使用下面的 shell 命令:

1
redis-cli KEYS "<pattern>" | xargs redis-cli DEL

改成实际的模式,如

1
redis-cli KEYS "user:*:followers" | xargs redis-cli DEL

表示删除 user followers 数据。

  1. 最后通过来回切换并重启 Redis 实例达到完全分割 Redis 实例。

ActiveSupport::Concern 的工作原理

| Comments

去年有一次的周记分享过 ActiveSupport::Concern (以下简称 Concern) 的用法,但是对它的实现原理一直不太明白,周末把它的实现源码仔细研究了一番,总算比较深入了解了它的工作原理,同时也让我对 Ruby 元编程有了更深的了解。

Concern 主要用于解决 module mix 的依赖问题,同时简化 module mix 的步骤。 关于依赖问题,必须用一个例子才好说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module Foo
  def self.included(base)
    base.class_eval do
      def self.method_injected_by_foo
        ...
      end
    end
  end
end

module Bar
  include Foo
  def self.included(base)
    base.method_injected_by_foo
  end
end

class Host
  include Foo # We need to include this dependency for Bar
  include Bar # Bar is the module that Host really needs
end

上面例子中 Host 为了 include Bar,必须得先 include Bar 依赖的 Foo,这是因为 Bar 在 include Foo 时,只是为 Bar extend method_injected_by_foo 方法,所以 Host 必须显式的 include Foo,才能够 extend method_injected_by_foo 方法。

使用 Concern 后,代码可以简写为

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
require 'active_support/concern'

module Foo
  extend ActiveSupport::Concern
  included do
    class_eval do
      def self.method_injected_by_foo
        ...
      end
    end
  end
end

module Bar
  extend ActiveSupport::Concern
  include Foo

  included do
    self.method_injected_by_foo
  end
end

class Host
  include Bar # works, Bar takes care now of its dependencies
end

Bar 和 Foo 都 extend ActiveSupport::Concern 后,Host include Bar 已经不需要事先 mix Foo。

这是怎么做到的呢,看看 Concern 的代码,其实代码很少:

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
module
  module Concern
    def self.extended(base)
      base.instance_variable_set("@_dependencies", [])
    end

    def append_features(base)
      if base.instance_variable_defined?("@_dependencies")
        base.instance_variable_get("@_dependencies") << self
        return false
      else
        return false if base < self
        @_dependencies.each { |dep| base.send(:include, dep) }
        super
        base.extend const_get("ClassMethods") if const_defined?("ClassMethods")
        if const_defined?("InstanceMethods")
          base.send :include, const_get("InstanceMethods")
          ActiveSupport::Deprecation.warn "The InstanceMethods module inside ActiveSupport::Concern will be " \
            "no longer included automatically. Please define instance methods directly in #{self} instead.", caller
        end
        base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
      end
    end

    def included(base = nil, &block)
      if base.nil?
        @_included_block = block
      else
        super
      end
    end
  end
end

关键部分是 append_features 方法,通过阅读 ruby 的文档和源码得知,ruby 在 include 一个 module 时,实际会触发两个方法,一个是 append_features,进行实际的 mixing 操作,包括增加常量,方法和变量到模块中,另外一个是 included 方法,也就是我们常用来作为 include 钩子的方法,默认的 included 是一个空方法,我们通过重载它使钩子起作用。

从代码可知,当一个模块 extend ActiveSupport::Concern 时,将产生 3 个影响:

  1. 为模块设置了一个实例变量

变量为 @_dependencies,其值为空数组,表示依赖的模块,将在 append_features 中用到;

  1. append_features 方法被重载;

重载后行为有了很大变化,它的处理分两种情况: 一种是当它被一个有 @dependencies 实例变量的模块被 include 时,直接把自身加到 @dependencies 中, 比如当 Bar include Foo 时,将触发 Foo 的 append_features(base) 方法,此时 base 是 Bar,self 是 Foo,由于 Bar 已经 extend ActiveSupport::Concern,Bar 的 @dependencies 有定义,所以直接把 Foo 加到 Bar 的 @dependencies 中,然后直接返回,没有立即执行 mixing 操作。

当 Host include Bar 时,将触发 Bar 的 append_features(base) 方法,此时 base 是 Host,self 是 Bar,Host 没有 extend ActiveSupport::Concern,所以 Host 的 @dependencies 无定义,将执行下面的分支,首先 include Foo(通过 Bar 的 @dependencies 获得 ),然后 include Bar (通过 super),然后是后续操作。

  1. included 方法被重载;

included 的动作比较简单,如果以没有参数形式调用,将把 block 存放到 @included_block 变量中,@included_block 的 block 将在 append_features 方法中使用。

通过施加的这 3 个影响,ActiveSupport::Concern 完成了它的全部功能,非常简洁精练。

从中学到的东西,包括:

  1. module 中 append_features 和 included 方法时 include 的核心;

  2. 可以通过覆盖这些方法改变操作的行为;

  3. 可以定义模块实例变量存储属于该模块的一些数据;