IM 同步之路

关于 IM 的同步,首先需要提的应当是协议,选择一个好的协议,对系统的扩展性,稳定性都有帮助。我们先来说一说协议的事。

公有协议 vs 私有协议

几乎所有 IM 开发起初都会考虑一下公有协议,即使最后没有选用,也会先对比一下各种协议的优劣。选用公有协议的好处显而易见,开发简单,兼容性好,很多协议也考虑了安全性。下面是两个比较流行的 IM 协议:

  1. XMPP,实现多,体积大,对移动端不友好
  2. MQTT,轻量级,移动端友好,消息送达率高

然而,选择了一个公有协议,也意味着更多的掣肘,协议的臃肿会让你的应用被迫实现很多不必要的接口。另外,扩展上面也不能随性所欲。所以,很多开发者或应用在积累了一定的基础之后,转而制定自己的私有协议。

反观私有协议,可以让应用更轻量,传输更少的数据,但是同时它也增加了开发的难度。我们粗略的看一下实现一个私有协议需要考虑哪些点。

连接协议

首先,选择使用什么连接协议,是你的私有协议的基石,QQ 在互联网萌芽时代选择了 UDP 连接,以支持大规模用户在线,而现代 IM 协议基本被 TCP 协议一统江湖,而且现在各类语言对应的 TCP 库也非常丰富。

信息载体

在信息的编码格式上有二进制和纯文本两种选择,序列化方式则有 xml, json, msgpack 等,综合下来,我认为使用纯文本+json,可读性和消息体积上面都比较理想。

连接/订阅/广播/断线

这四个步骤是一个 IM 协议最基础的功能,也是一个会话从开始到结束的最简单流程,协议中需要明确定义这四个步骤的接口

其他需要考虑的问题

  1. QOS(quality of service),一个好的 IM 协议应该可以为消息设定不同的等级,以便在效率和送达率上面找到一个平衡点,开发者也可以根据实际需求为消息选择是必须确认送达还是可容忍一定的丢失率
  2. 安全性,消息是否需要加密,使用什么加密算法,也是可以在协议中约定的,不过加密必然导致效率降低,如果消息重要程度不高,则可以不为消息加密,据说 QQ 多年以来一直使用明文传输消息(未考证哦)

Restful vs 消息队列

从前后端分离的架构上来看,Restful 是一种理想的方式,接口相对标准化,有规律可循。但是从节约流量的角度看,这并不是一种很理想的方案。

IM 应用对数据同步的实时性要求较高,如果使用 Restful 接口,就会存在需要反复查询同一接口,得到的结果重复率较高的问题。例如简聊的用户信息接口 https://jianliao.com/v2/users/me,在每次进入应用的时候都会查询这个接口,每次得到的数据几乎 99% 都是和上次请求的时候相同的,而为了这 1% 的区别,又不得不反复查询。

而如果将数据离线存储,通过消息队列的方式来更新,则会让请求数量大大减少,加载效率就会得到大幅提升。

简聊在 3.0 的更新中同时也改变了以往 Restful 同步数据的方式,将每次最新的数据对象保存在队列中,客户端则通过队列去同步本地数据,从目前的实现方案来说,改善了频繁抓取热数据的情况,但是仍然有一些遗留问题值得优化和解决:

  1. 队列中保存的数据为完整对象,而不是操作记录,这样做是因为保存完整数据可以避免客户端在同步状态异常时出现无法恢复的脏数据,然而这样在流量上面开销就大得多了。
  2. 队列只追加不更新,这样的一个结果就是导致在队列内部也会出现重复数据,增加了服务端存储的空间,也增加了流量的开销。

数据库同步

最近研究同步的问题,还发现了另一种很有意思的方案,我感觉在未来会成为一种趋势,值得关注。

同步存在于我们开发中的方方面面,在服务端,我们使用数据库集群,以达到横向扩展的需求。做版本控制,我们选择 git,push 和 pull 也是在做同步。是通过版本管理和操作记录,客户端实现增量的同步服务端数据,最后达到保存镜像的目的。

其实做 IM 应用,客户端和服务端的同步,也同样可以借鉴这种思想。现在的移动客户端都使用本地数据库,Web 也有 LocalStorage 可用,所以在离线存储上面不存在问题。于是有人想到了一个方法,来使客户端和服务端保持数据同步,那么在业务上面,前后端就分离的更加清楚了,这就是 PouchDB

PouchDB 借鉴了 CouchDB 的分布式思想,是一个 JavaScript 版本的实现,以便能在客户端利用和 CouchDB 一模一样的接口,由于在数据层进行同步,与业务逻辑无关,双方只需要协商好需要的数据表结构,就可以安心的去做自己平台的事了。

但是同时也留下值得思考的问题:

  1. 怎样将服务端的多用户关系表转换成客户端的单用户数据
  2. 因为同步是双向的,怎样将客户端的单用户数据转换为服务端的多用户关系表
  3. 当服务端数据结构升级时,怎样解决兼容性问题
  4. 大数据量时,diff 的效率非常低下,大量的 diff 会拖慢应用的速度

拓展

如何写出好爬虫

写爬虫可以说是一个程序员必修课,因为上手简单,成效明显,深受各大培训机构和教学材料的青睐。于是无数新手也加入了造轮子的队列,写的爬虫满天飞。

说爬虫上手简单,是有原因的,只要了解了 http 请求和 html/xml 结构,谁都能做出一个可用的爬虫,再辅以一个好用的请求库和解析库,完成老板的任务简直就是分分钟的事情,老婆孩子再也不用担心我加班了有木有?

———————- 下面是转折的分割线 ———————-

但是,这样写出来的爬虫真的达到了生产级别吗,答案是否定的。码农界常说的一个词是 Robust,来看看怎样让我们的爬虫变得更加 Robust

编码问题

互联网上存在各种各样的编码,虽然现代的浏览器都能智能的识别编码,但是对于爬虫来说它们却是需要跨过的第一道坎。

多数网站都会在响应头中给出本站编码,我们只需要检测响应头中 content-type encoding 就能准确的得到站点的编码。再不济也会在 html meta 中标识出编码内容,检测 meta 中的 charset 属性就行了,例如 <meta http-equiv="Content-Type" content="text/html" charset="utf-8"> 就表示该网页使用的是 utf8 编码。

如果站长丧心病狂到什么提示都不给,那么我们就只能靠猜了,iconv 是一个不错的库,可以检测文本内容的编码,而且准确率比较高,在各种语言中也能找到对应的 iconv 模块。

如果最后还是检测不出对应的编码,那就默认当做目前最常见的 utf8 编码吧,这段简单的代码片段可以说明检测编码的过程。

压缩问题

某些网站会在响应内容是使用 gzip 压缩以便节约流量(例如整天被爬的知乎同学),遇到这种站点,爬虫也需要做特殊的解析才能得到最后的纯文本内容。

解析的过程与检测编码类似,先通过响应头检测网站使用的是什么压缩编码,然后使用对应的解压缩方法解压内容即可,使用压缩的网站会在响应头 Content-Encoding 中加上压缩编码,例如 Content-Type: gzip 就表示该网页使用的是 gzip 压缩,这段代码可以说明检测压缩编码的过程

添加 User-Agent

下面的问题就是与内容提供者斗智斗勇的过程了,有些站点会识别爬虫行为然后屏蔽掉一些爬虫的请求。我们要做的就是尽量让我们的爬虫看起来,像个人。

首先请求头中必须添加 User-Agent,如果你还不知道 user-agent 是什么,出门左转看了wiki再回来。由于 ip 轻易无法修改,但是 user-agent 却是可以任意修改的,如果内容网站条件较宽,只使用 user-agent 识别爬虫的话,那么我们就乖乖的写上一些常用浏览器的 user-agent,如果你不知道有哪些,去这里可以查到所有的 ua。

至于专业的搜索引擎,内容网站欢迎还来不及,怎么会屏蔽呢。所以它们一般都会使用自己的特定 ua 标识,例如 Baidu spider,Google spider 等,普通野生的爬虫就不要参考了。

合并相同请求

如果我们的内容提供者地址是由用户添加的,难免碰到会有重复地址的情况,这时最好就是将这些链接给合并掉,以免重复请求。例如 A 和 B 同时添加了链接 abc,那么我们的爬虫只需请求一次 abc 然后将内容分别返回给 A 和 B 就行了,或者请求 abc 之后缓存一段时间,当其他用户添加这个链接时,返回缓存内容就行了。我们的宗旨就是用最少的请求干最多的事。

错误

有的时候爬虫在请求网站时会碰到一些错误,这些有的是由于内容网站的错误,有些是由于权限或受到了屏蔽,在大多数情况下面这些错误都可以通过 http status code 来分辨(如果不知道什么是 http status code,再出门去看一遍 wiki)。

如果区分每个 http status code 太麻烦,有一个基本的原则就是看 code 前缀,例如:

  • 3xx 开头的表示这个地址被跳转到其他地址了,爬虫可以根据响应头跟进,很多库自动做了跳转跟进,返回最终网址的内容,所以找到合适的请求库最重要,例如request
  • 4xx 表示内容网站拒绝了你的请求,有可能是你的 ip 被屏蔽,或者这个地址需要验证。遇到这种情况,可以等待一段时间再次请求,并逐渐增加请求间隔,直到返回的响应头为 2xx。一般的屏蔽会在一段时间后解除。
  • 5xx 表示内容网站挂了或你的服务器网络挂了,遇到这种情况,我们能做的就只能等,等内容网站修复错误。

连续错误次数过多的网站就不要再爬取了,以便节约带宽资源

请求频率

上面提到了错误重试的问题,这一节我们聊一下调整请求频率以防止被屏蔽。

如果你的爬虫现在可以正常访问内容网站,不要玩的太 high,尽量约束一下自己的请求频率,一分钟一次已经是很多网站能够忍耐的底线了,如果内容更新不频繁,可以设置为 20 分钟一次。

此外,还能制定一些比较聪明的策略,例如发现网站内容较上次没有更新,那么下次请求间隔设置为 1.5 倍,依次递增,直到你设定的请求间隔上限。如果内容有了更新,再把请求间隔重置为最小值。这样既不影响及时得到网站的更新,也会尽量减小被屏蔽的风险。

如果你的爬虫 ip 已经被屏蔽了(怎样判断被屏蔽请参考上文),那么就消停一会儿,设定一个较长的请求间隔再尝试,直至解除屏蔽。

分布式

爬虫受制于单台服务器的带宽和请求数限制,往往在高配置的机器中也无法发挥最大的效能。所以将爬虫分布在多台低配高带宽的服务器上是比较合理的做法。至于如果分布式,这个话题聊起来就没完了,可以使用最基础的消息队列(例如 zmq, rabbitmq 或 redis)和经典的 master/worker 结构,来实现多台机器协同工作。

大数据

大数据没有以前那么火了,但是为了提高这篇文章的逼格,仍然是一个值得一提的话题。

我们知道互联网的世界是开放的,很多内容其实不需要我们亲自去爬取,搜索引擎已经帮我们收录了这些网站内容,合理利用搜索引擎的 site: 命令,有时候可以得到比亲自爬取更满意的结果。

Don’t be evil

大部分网站会在站点根目录下包含一个 robots.txt 文件,可以通过 http://域名/robots.txt 来访问,里面的内容表明了站长对于爬虫的限定,如果发现某些内容是站长不希望你爬取的,那么还是乖乖绕过吧。如果不知道 robots.txt 是什么,再去一次 wiki。

结语

以上简单介绍了一下我多年以来作为一名非大数据非分布式非社区明星开发的经验,抛砖引玉,希望能对读者有所帮助。

CEO 的坏习惯

不计后果的答应客户

在签约或谈合作时为了取得订单,不考虑现有能力和需要投入的成本,盲目的满足客户的要求,这样的做法,和乡下金属加工厂的老板没有任何区别,订单来了,增加机器和工人,加班加点,就能满足需求了。软件行业不是劳动力密集型产业,一步考虑不周,就需要投入更大的成本去弥补。国内那么多员工众多的软件公司,却开发不出好用的软件,UI 和交互都停留在上个世纪,很大一部分原因,都是由急功近利的老板造成的。

无法保持专注

很多 CEO 觉得自己的学习能力比别人强,还兴趣广泛,对公司里的所有工作都喜欢亲自参与。这本无可厚非,问题就出在他是 CEO。到了任何一个部门,都是公认的 Leader,员工很难在这样的关系下保持“有自己的意见”,创新更无从谈起。在 CEO 亲身投入到这个部门的时期,员工可能会斗志满满,一旦离开这个部门,立刻就会回到六神无主的状态。做任何工作,都需要保持专注,在很长的一段时间,Leader 都要和大家共进退,浅尝辄止不如放任自流。不管在哪个时期,这些工作,都有比 CEO 更合适的人或团队存在。

这一点同样体现在产品上,CEO 会比员工体验到更多的竞争对手压力,而这个时候如果忙于追赶竞争对手,而不再从产品自身的实用性和设计理念出发,那么员工就变成了竞争的牺牲品,心力憔悴的复制着对手的功能。我们常说“好产品不愁没市场”,不忘初心,保持专注,才是打造好产品的前提啊。

屏蔽不感兴趣的声音

每个人都有自己的兴趣点,甚至在不同的年龄和时间,兴趣也不一样。CEO 就像一个公布在社交网络上的收件箱,每时每刻都有不同的声音到来。我们常说兼听则明,偏信则暗。上文提到要常对客户的需求说不,但是对于员工的声音,则要时时去鼓励。员工的声音,与公司息息相关,一个建议可能挽救一个产品,一个创意可能占领一个市场,CEO 如果不能从内部的建议中博采众长,那么难免陷入到一意孤行的境地。

不能知人善用

创业公司常常出现的一种情况就是一人需要干几个人的事,在创业萌芽期,这样做只能说是万不得已,而且因为功能少,事情也不显得复杂,一个人干多件事情也是一种磨练。到了公司业务走上正轨,产品越来越庞杂,再让一个人干几个人的事情,就不是那么合理了。我想每个人都希望成为某个领域的专家,这样不仅是为了个人前途考虑,对公司也有好处,如果能将员工培养成某一方向的专家,那么遇到问题自然不愁没人解决。业务拓展了,资金充裕了,公司就更应该给员工创造培养提高的环境。这个时候让与系统架构息息相关的运维去给客户装机部署,让开发去做社区运营,就不再是一个有远见的 CEO 干的事了。

关于吃饭

最近好像有款叫 Soylent 的食物(?)突然在网上流行起来,到哪儿都能看到有人推荐这款产品。保守如我,在心理上实在接受不了每天就靠喝这种芝麻糊过日子。

什么时候吃饭变成了一件麻烦事

生活节奏越来越快,我已经不记得上次做饭是什么时候了。我倒是不太排斥烧菜的过程,真正的麻烦都在准备食材的阶段,如果手边有现成洗净的食材,我是很愿意煎炒蒸煮一下的。在身体健康的年纪,吃遍天下美食应该是一种美好的享受吧,食物和调料带来的快乐可不是一大杯蛋白质糊可以比拟的。

就算在快节奏的工作日,吃一顿饭也占用不了太多的时间。Soylent 貌似并不比去路边吃顿饭更省事,如果专为懒人设计,我建议他们将冲泡壶设计成一次性的,吃完就丢,岂不更方便。

吃饭真的只是为了饱腹吗

吃饭也是一种社交活动,吃饭的时候和别人聊聊热门话题,开拓一下视野,转移一下注意力,既是休息,也是增进了解的一种方式。大家围成一桌喝着芝麻糊聊天的场景,画面太美,实在不敢想想。

不过这年头新产品和新概念层出不穷,不断刷新人们的世界观,出现 Soylent,也算是科技和互联网发展的一种副产品。也许以后该转换一下思路,能保持独立思考,同时接受新事物。

为了这个写一篇文章确实慢蛋疼的,晚饭加个蛋压压惊。

Node.js 沙盒

为什么要使用沙盒

eval 在很多语言中都是一个很有用的方法,合理利用它可以编写出很多让人拍案叫绝的功能。但是由于它实在过于开放和危险,很多人给它冠上了 evil 的称号。

使用沙盒可以给 eval 类的功能增加一些条件限制,让它变得更加安全,而不丢失其灵活性。

Node.js 中提供的 vm 模块可以轻松实现沙盒的功能。

如何使用 vm 模块

vm.runInThisContext

vm.runInThisContext 可以执行代码并得到它的返回值,被执行的代码没有权限访问本地对象,但是可以访问全局对象。相比之下, eval 则有权限访问上下文中的对象。

1
2
3
4
5
6
7
8
9
10
11
12
var localVar = 'initial value';

var vmResult = vm.runInThisContext('localVar = "vm";');
console.log('vmResult: ', vmResult);
console.log('localVar: ', localVar);

var evalResult = eval('localVar = "eval";');
console.log('evalResult: ', evalResult);
console.log('localVar: ', localVar);

// vmResult: 'vm', localVar: 'initial value'
// evalResult: 'eval', localVar: 'eval'

vm.createContextvm.runInContext

vm.createContext 则是真正创造了一个沙盒对象,使用 vm.runInContext 可以完全让代码在这个沙盒环境中运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var util = require('util');
var vm = require('vm');

sandbox = vm.createContext({ globalVar: 1 });

for (var i = 0; i < 10; ++i) {
    vm.runInContext('globalVar *= 2;', sandbox);
}

console.log(util.inspect(sandbox));
console.log(global.globalVar);

// { globalVar: 1024 }
// undefined

vm 的具体应用

configd 是我为公司部署流程开发的一个小工具,功能是将各种来源的配置文件合并成一个 json 文件。由于它支持 ssh, git, http 等多种来源的配置或代码,所以需要在工具内部来执行这些代码以实现和本地 require 类似的效果。如果用 eval,那么除却风险问题,module.exports 也不能生效了。所以在工具中使用了 vm 模块来执行这些代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_eval = (js, options = {}) ->
  sandbox = vm.createContext()
  sandbox.exports = exports
  sandbox.module = exports: exports
  sandbox.global = sandbox
  sandbox.require = require
  sandbox.__filename = options.filename or 'eval'
  sandbox.__dirname = path.dirname sandbox.__filename

  vm.runInContext js, sandbox

  sandbox.module.exports

data = _eval js

参考资料

Executing JavaScript

NCR 是什么

一直以来,对爬虫抓取的某些内容感到很费解,比如形似 &#x4e2d;&#x56fd; 的字符串,最初以为是经过编码的 unicode 字符,但是尝试了各种手段都无法解开。最近开始关注起这个问题,机缘巧合在知乎上搜到了一个回答,原来这些是叫做 numeric character reference (NCR) 的转义序列,在这里记录一下,权作备忘。

以下一段引用自知乎梁海的回答

形如 ——

&#dddd;

&#xhhhh;

&#name;

—— 的一串字符是 HTML、XML 等 SGML 类语言的转义序列(escape sequence)。它们不是「编码」。

以 HTML 为例,这三种转义序列都称作 character reference:

前两种是 numeric character reference(NCR),数字取值为目标字符的 Unicode code point;以「&#」开头的后接十进制数字,以「&#x」开头的后接十六进制数字。 后一种是 character entity reference,后接预先定义的 entity 名称,而 entity 声明了自身指代的字符。

从 HTML 4 开始,NCR 以 Unicode 为准,与文档编码无关。

「中国」二字分别是 Unicode 字符 U+4E2D 和 U+56FD,十六进制表示的 code point 数值「4E2D」和「56FD」就是十进制的「20013」和「22269」。所以 ——

&#x4e2d;&#x56fd;

&#20013;&#22269;

—— 这两种 NCR 写法都会在显示时转换为「中国」二字。

至于怎么去 encode/decode 这些字符,在网上找到了一个简单的 Javascript 版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String.prototype.ncr2c = function( ) {
  return this
    .replace( /&#x([\da-f]{2,4});/gi,
    function( $0, $1 ) { return String.fromCharCode( "0x" + $1 ) } )
}
String.prototype.c2ncr = function( ) {
  return this .ncr2c( ).replace( /./g,
    function( $0 ) { return "&#x" + $0.charCodeAt( ).toString( 16 ).toUpperCase( ) + ";" } )
}

alert( "&#x61;&#x6A;&#x61;&#x78;".ncr2c( ) );
//ajax

alert( "a&#x6A;ax".c2ncr( ) );
//&#x61;&#x6A;&#x61;&#x78;

由于上面的方案是以修改 String 原型来实现的(一般不认为这是一个好做法),使用的时候可以改成两个方法。

而另一种更 robust 的方案则是使用第三方库,例如 he 模块,轻松实现对很多转义序列的支持。

Multipart 到底是个啥玩意儿

在 POST 请求中,常见的几种请求头有 ‘application/x-www-form-urlencoded’, ‘application/json’,这些都很容易理解,唯独 ‘multipart/form-data’ 这种请求挺让我费解,下面就来详细说明一下,以作笔记备查。

在 GET 请求中,参数一般会以 ‘&’ 为分割符号,比如 ‘http://api.example.com?name=tom&friend=jerry’,在 ‘application/x-www-form-urlencoded’ 类的 POST 请求中,参数形式与此类似,只不过参数被写在了 body 中,以突破 url 中 2k 字节的限制。

而当我们想上传文件或其他二进制数据时,根据 form 标准,非字符串会被替换成 ‘%HH’,其中的 ‘HH’ 是两个十六进制数来表示当前这位的二进制数据。在上传大文件的时候,这种做法就显得非常浪费了。于是,我们经常会把 ‘multipart/form-data’ 来用在文件上传中。

我们来看一个完整的 ‘multipart/form-data’ 请求:

1
2
3
4
5
6
7
8
9
10
11
12
Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x
Content-Disposition: form-data; name="submit-name"

Larry
--AaB03x
Content-Disposition: form-data; name="files"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x--

上面 ‘boundary’ 的值,就与常见的 ‘application/x-www-form-urlencoded’ 中 ‘&’ 的作用差不多了,在接收请求的服务器中,会将 body 以 ‘–AaB03x’ 分割出一个个 part,就能正常解析出参数类型,名称,文件名等等内容了。

参考资料

Sort 的错误用法

前不久同事的代码中出了一个很神奇的问题,大致流程是对一个由对象组成的数组进行排序,其中属性 a 用于排序,属性 b 作为一个优选条件,当 b 等于 1 的时候无论 a 值是什么,都排在开头 。这本是一个很简单的问题,问题就在于他用两次 sort 实现在这次排序,先根据 a 的属性排序,然后再根据 b 的值来排序。问题就出在第二次排序中。

我们想当然的会认为在第一次排序中,数组已经根据 a 的属性由大到小排序,在第二次中我们只要不去动原数组的顺序就行(一般在方法中写成返回0或-1),只考虑单独把 b 等于 1 的元素提到前面去。但是其实这与语言所选用的排序算法有关,javascript (和一起其他语言)内置的 sort 方法采用的是几种排序算法的集合,有时并不能保证相同元素的位置保持一致。

下面是从 stackoverflow 上面找来的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var arrayToSort = [
  {name: 'a', strength: 1}, {name: 'b', strength: 1}, {name: 'c', strength: 1}, {name: 'd', strength: 1},
  {name: 'e', strength: 1}, {name: 'f', strength: 1}, {name: 'g', strength: 1}, {name: 'h', strength: 1},
  {name: 'i', strength: 1}, {name: 'j', strength: 1}, {name: 'k', strength: 1}, {name: 'l', strength: 1},
  {name: 'm', strength: 1}, {name: 'n', strength: 1}, {name: 'o', strength: 1}, {name: 'p', strength: 1},
  {name: 'q', strength: 1}, {name: 'r', strength: 1}, {name: 's', strength: 1}, {name: 't', strength: 1}
];

arrayToSort.sort(function (a, b) {
  return b.strength - a.strength;
});

arrayToSort.forEach(function (element) {
  console.log(element.name);
});

我们会以为最后元素的值还是从 a 到 t,但实际运行下来的结果却是乱序的,这是因为 sort 的算法并没有保留原数组的顺序,也即 unstable

那么我们就该尽量避免这种情况发生,就我同事的例子,将两次 sort 的逻辑合并在一次中应该是个可行的办法,如果必须分为多次 sort,那么就把原数组的顺序记录在元素的属性上把。

用 Heredoc 写 Mongo Shell

mongo shell 给我们提供了很便捷的 mongodb 操作接口,很多人应该用过 mongo 命令执行 javascript 文件,或者通过 mongo --eval 执行脚本。两种方式各有千秋,使用 js 文件可编辑较复杂的代码逻辑,而且可以作为脚本储存以备重复使用。使用 mongo --eval 比较灵活,随取随用,但是当代码中有换行时,就蛋疼了。mongo 官方的文档 并没有提到 eval 在处理多行代码时的解决方案,好在最近发现用 heredoc 可以完美的解决这个问题。

heredoc

heredoc 在 wiki 上解释为一段可被当做独立文件的代码片段,一般表现为下面这种形式:

1
2
3
4
tr a-z A-Z <<END_TEXT
one two three
uno dos tres
END_TEXT

这里的 <<END_TEXTEND_TEXT 就是 heredoc 了,虽然语法简单,用处可就大了。在这里正好解决了在 mongo --eval 中遇到的问题,由于这段字符串可被当成文件来使用,所以直接跟在 mongo 命令后面就行

1
2
3
mongo localhost/test <<MONGO
db.users.save({name: "mongo"})
MONGO

在编写 mms 这个迁移模块的时候,如果没有 heredoc,则不免需要生成一些 js 临时文件来给 mongo 执行,现在,直接拼接成字符串就行

最后,使用中不要忘了将一些字符转义掉,比如 ‘$’,以免被当成 shell 变量引用了。

利用 Nginx 实现静态资源的反向代理

github 中很多项目都有一个 readme 文件,很多人喜欢在文件中添加自己的创作或封面图片,比如 substack 为他的每个项目绘制了一个 logo。这些图片在 github 中能直接在页面中显示出来,不过 url 被替换成了 github 自己的。比如在 browserify 项目中,logo 的链接变成了

https://camo.githubusercontent.com/e19e230a9371a44a2eeb484b83ff4fcf8c824cf7/687474703a2f2f737562737461636b2e6e65742f696d616765732f62726f777365726966795f6c6f676f2e706e67

而我们通过查看 raw 能发现原 url 是

http://substack.net/images/browserify_logo.png

这样做的一个好处是防止因为在 https 网站中出现 http 链接,否则在客户端会得到一个风险警告。github 在细节上真是考虑的十分周到。

既然有需求,我们就来实现它。通常的做法是写一个应用去抓取远程的静态资源,然后反馈给前端,这就是一个简单地反向代理了。但是这样做比较繁琐,效率也未见得高,其实我们可以直接通过 nginx 来代理这些静态文件。

nginx 的 proxy_pass 支持填写任意地址,并且支持 dns 解析。所以我的思路是,将原 url 加密转成网站自身的 url。比如上面的

http://substack.net/images/browserify_logo.png

可以加密成

764feebffb1d3f877e9e0d0fadcf29b85e8fe84ae4ce52f7dc4ca4b3e05bf1718177870a996fe5804a232fcae5b893ea (加密和序列化算法网上有很多,在此就不赘述了)

然后放在我们自己的域名下:

https://ssl.youdomain.com/camo/764feebffb1d3f877e9e0d0fadcf29b85e8fe84ae4ce52f7dc4ca4b3e05bf1718177870a996fe5804a232fcae5b893ea

解密的步骤用 nginx 会比较难实现,所以当用户通过上述链接请求时,先讲请求传递给解密程序,这里有一个 coffeescript 版本的例子:

1
2
3
4
5
6
7
8
9
10
11
express = require 'express'
app = express()
app.get '/camo/:eurl', (req, res) ->
  {eurl} = req.params
  {camoSecret} = config  # 这里使用自己的密钥
  rawUrl = util.decrypt eurl, camoSecret
  return res.status(403).end('INVALID URL') unless rawUrl
  res.set 'X-Origin-Url', rawUrl
  res.set 'X-Accel-Redirect', '/remote'
  res.end()
app.listen 3000

然后写入 X-Accel-Redirect 响应头做内部跳转,下面的步骤就由 nginx 完成了。

下面是一个完整的 nginx 配置文件例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server {
    listen 80;
    server_name ssl.youdomain.com;
    location /camo/ {
        proxy_pass http://localhost:3000;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_redirect off;
        break;
    }
    location /remote {
        internal;
        resolver 192.168.0.21;  # 必须加上 dns 服务器地址,否则 nginx 无法解析域名
        set $origin_url $upstream_http_x_origin_url;
        proxy_pass $origin_url;
        add_header Host "file.local.com";
        break;
    }
}

nginx 的 upstream 模块会把所有的响应头加上 $upstream_http_ 前缀当成一个变量保存,所以在上面的例子中我们将原 url 放在 X-Origin-Url 响应头中,在 nginx 就变成了 $upstream_http_x_origin_url 变量,但是在 proxy_pass 中不能直接引用,非要通过 set 来设置才能引用,这个我不是很理解,希望有高手能解答。

这样下来,每次当用户请求

https://ssl.youdomain.com/camo/764feebffb1d3f877e9e0d0fadcf29b85e8fe84ae4ce52f7dc4ca4b3e05bf1718177870a996fe5804a232fcae5b893ea

时,nginx 就会去抓取

http://substack.net/images/browserify_logo.png

的内容返回给用户。我们还可以在 nginx 之前加上 varnish,用以缓存静态文件的内容。这样就跟 githubusercontent 的做法更加一致了。