Why Y

加密与签名的区别

最近要在 Redis 里放一点敏感数据,于是就在 Rails 里找实现加密的东西。Rails 有 cookies.encryptedcookies.signed 两个方法,区别一直没搞明白,这次研究了一下,终于明白了。

加密

加密的目的是保证数据只能通过秘钥才能查看,没有秘钥看不到任何数据。

签名

签名的目的是保证数据不会被篡改或伪造,但是可以在没有秘钥的情况下查看原数据!

示例

ActiveSupport 中有加密和签名的功能,对应的类是 ActiveSupport::MessageEncryptor 和 ActiveSupport::MessageVerifier。下面的例子展示了它们的用法以及区别。

1
2
3
4
5
6
7
8
9
secret = 'secret' * 10
encryptor = ActiveSupport::MessageEncryptor.new(secret)

encrypted_data = encryptor.encrypt_and_sign('You cannot see me')

# with secret
encryptor.decrypt_and_verify(encrypted_data)
# => "You cannot see me"
# You can do nothing without secret
1
2
3
4
5
6
7
8
9
10
11
12
secret = 'secret' * 10
verifier = ActiveSupport::MessageVerifier.new(secret)
signed_data = verifier.generate('You cannot see me')

# with secret
verifier.verify(signed_data)
# => "You cannot see me"

# without secret !!!
encoded = signed_data.split('--').first
Marshal.load(Base64.strict_decode64(encoded))
# => "You cannot see me"

值得一提的是,ActiveSupport::MessageEncryptor 除了加密,也提供了签名的功能,从方法名中可以看出这一点。

参考

Reading Rails - How Does MessageVerifier Work?

Reading Rails - How Does MessageEncryptor Work?

jQuery Utils Replacement

曾几何时,编写跨浏览器的 JavaScript 代码是噩梦一般的存在。在那个浏览器差异巨大、标准提供的功能有限的时代,jQuery 出现后,依靠跨浏览器、功能丰富、接口简洁优雅的特性,迅速成为了前端开发的事实标准。直到现在,jQuery 可能依然是最流行的 JavaScript 库。但是到了现在,现代浏览器不再各自为战,开始遵循相同的标准,而标准本身也在前进,不断加入新的接口。很多逻辑已经可以使用原生的接口实现,不再依赖第三方库。

在我看来,jQuery 的 DOM 操作接口依旧比原生接口好用,还是很难丢弃掉。但很多实用函数已经可以使用原生接口替代。下面整理了几个 jQuery 常用的实用函数,以及对应的原生接口。

$.each() 和 $.fn.each()

分别使用 Array.prototype.forEach()NodeList.forEach() 替代。

曾经 JavaScript 只能不停地使用循环来实现遍历,ECMAScript 5 新增了数组等类型的 forEach 方法,因此已经可以不使用 jQuery 的遍历函数了。而且现在最新版本的浏览器也开始支持 NodeList 实例的 forEach 方法。需要注意的是参数顺序是不一样的,forEach 的参数顺序更方便一些,因为很多时候并不需要下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$.each(array, function (i, item) {

}

array.forEach(function (item, i) {

}

$(selector).each(function (i, el) {

}

document.querySelectorAll(selector).forEach(function (item, i) {

}

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach

https://developer.mozilla.org/en-US/docs/Web/API/NodeList/forEach

$.extend()

使用 Object.assign() 替代。

很多库和框架都有自己的对象合并方法,方法名称一般是 extend 。ECMAScript 2015 新增了原生的对象合并方法,只不过名字叫 assgin

1
2
3
$.extend({}, objA, objB)

Object.assign({}, objA, objB)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign

https://github.com/nzakas/understandinges6/issues/99

$.map()

使用 Array.prototype.map() 替代。

同样需要注意参数顺序。

1
2
3
4
5
6
7
$.map(array, function(value, index) {

}

array.map(function (value, index) {

}

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map

$.proxy()

使用 Function.prototype.bind() 替代。

函数的 bind 方法是 EMCAScript 5 中新增的非常重要的一个方法,有了它可以尽量避免 that, self 之类的愚蠢变量名。需要注意的是 bind 每次调用都返回一个新的函数,而 $.proxy 在内部做了缓存处理,多次调用返回的是同一个函数。

1
2
3
$.proxy(fn, context)

fn.bind(context)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

http://stackoverflow.com/questions/18848343/underscore-bind-vs-jquery-proxy-vs-native-bind#answer-22860661

$.trim()

使用 String.prototype.trim() 替代。

MDN 中给出了一个 polyfill 的方法,jQuery 的实现几乎与其一样,$.trim() 不是字符串类型的方法,所以对 null 做了特殊处理,$.trim(null) === ‘’

1
2
3
$.trim(string)

string.trim()

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim

总结

本文是阅读 You Might Not Need jQuery 后写的,页面上有一个选择支持 IE 最老版本的按钮,通过切换不同的开关,可以看到越新的浏览器版本,实现相同功能的代码越简洁统一。相信在浏览器厂商和标准的共同努力下,可以让我们更好得使用原生接口编写前端代码。

Rails Time Zone

前几天发现自己写的 Rails 项目数据库里的时间不对,与实际时间相差了八个小时。作为一个生活在东八区的人,很容易理解这是一个与时区相关的问题。

首先检查 Rails 的配置,发现没有与时区相关的配置,说明这是 Rails 的默认行为。查了一些资料后,慢慢理解了 Rails 是如何处理时区的。

首先是如何向数据库中插入时间。通过数据库迁移生成的时间和日期类型的字段是没有时区信息的。created_at 在 PostgreSQL 中是 timestamp without time zone 类型的,在 MySQL 中是 datetime 类型的,显然都是不带时区信息的。Rails 默认在数据库中保存 UTC 时间,也可以通过修改配置让数据库中保存的是本地时间。

1
2
3
4
5
6
# config/application.rb

# default, store utc time
config.active_record.default_timezone = :utc
# store local time
config.active_record.default_timezone = :local

到这里就明白为什么数据库里的时间与实际时间相差了八个小时了。所谓的“实际时间”就是本地时间,北京时间,东八区时间,对应的 UTC 时间就是减掉 8 个小时的时间。

1
2
3
4
5
6
7
8
9
10
11
12
# Rails console
[5] pry(main)> Bar.find(1).created_at
Bar Load (0.3ms)  SELECT  "bars".* FROM "bars" WHERE "bars"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 CST +08:00

# PostgreSQL console
pg_development> select created_at from bars where id = 1;
+----------------------------+
| created_at                 |
|----------------------------|
| 2016-06-29 15:15:25.769707 |
+----------------------------+

可以看到 Rails 自动对数据库里时间做了处理,返回的时间是带有时区信息的。

Rails 可以从哪些地方获取到时区的信息呢?有两种方式。

操作系统

第一种是直接从操作系统获取。操作系统都是有时区配置的,Ruby 的 TimeDateTime 类可以获取到操作系统的时区。下面几行命令的输出可以看到时区的影响。

1
2
3
4
5
% ll /etc/localtime
lrwxr-xr-x  1 root  wheel    33B  7  9 18:08 /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai

[90] pry(main)> Time.now
=> 2016-07-10 15:12:52 +0800
1
2
3
4
5
% ll /etc/localtime
lrwxr-xr-x  1 root  wheel    30B  7 10 16:13 /etc/localtime -> /usr/share/zoneinfo/Asia/Tokyo

[93] pry(main)> Time.now
=> 2016-07-10 16:13:57 +0900

当 OS X 的时区从东八区改为东九区时,Time.now 返回的时间也相依的发生变化。其实这种获取时区的方式与 Rails 无关,更准确的说这是 Ruby 获取时区的方式。

Rails

第二种方式是在是通过配置文件设置 Rails 应用的时区。

1
2
3
4
5
6
# config/application.rb
config.time_zone = 'UTC'

config.time_zone = 'Beijing'

config.time_zone = 'Tokyo'

config.time_zone 设置 Rails 默认使用的时区,默认值是 ‘UTC’,它会影响到 Rails 里时间的显示,比如:

1
2
3
4
5
6
7
8
9
# config.time_zone = 'UTC'
[11] pry(main)> Bar.first.created_at
  Bar Load (1.2ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 15:15:25 UTC +00:00

# config.time_zone = 'Beijing'
[1] pry(main)> Bar.first.created_at
  Bar Load (0.5ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 CST +08:00

可以看到,相同的时间,因为默认时区的不同,显示是不同的。如果我们把 UTC 时间当做绝对时间,设置时区只是让 Rails 把绝对时间显示为对应时区的时间。除了默认的时区设置,还有其他设置时区的方式。

Time#zone=

Rails 扩展了 Time 类,增加了 zone= 等方法,config.time_zone = 'Beijing' 实际上就等价于 Time.zone = 'Beijing',只不过使用 Time#zone= 可以重设默认时区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# config.time_zone = 'Beijing'
[1] pry(main)> Bar.first.created_at
  Bar Load (0.4ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 CST +08:00

[2] pry(main)> Time.zone = 'Beijing'
=> "Beijing"
[3] pry(main)> Bar.first.created_at
  Bar Load (0.4ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 CST +08:00

[4] pry(main)> Time.zone = 'Singapore'
=> "Singapore"
[5] pry(main)> Bar.first.created_at
  Bar Load (0.3ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 SGT +08:00

[6] pry(main)> Time.zone = 'Tokyo'
=> "Tokyo"
[7] pry(main)> Bar.first.created_at
  Bar Load (0.3ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Thu, 30 Jun 2016 00:15:25 JST +09:00

ActiveSupport::TimeWithZone#in_time_zone

Time#zone= 修改时区后会影响到所有时间的显示,in_time_zone 只针对一个对象,不会影响其他的时间对象。

1
2
3
4
5
6
7
8
9
10
11
[1] pry(main)> t = Bar.first.created_at
  Bar Load (0.5ms)  SELECT  "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> Wed, 29 Jun 2016 23:15:25 CST +08:00
[2] pry(main)> t.in_time_zone('UTC')
=> Wed, 29 Jun 2016 15:15:25 UTC +00:00
[3] pry(main)> t.in_time_zone('Singapore')
=> Wed, 29 Jun 2016 23:15:25 SGT +08:00
[4] pry(main)> t.in_time_zone('Tokyo')
=> Thu, 30 Jun 2016 00:15:25 JST +09:00
[5] pry(main)> t
=> Wed, 29 Jun 2016 23:15:25 CST +08:00

时区的影响

目前看来,不管如何设置时区,貌似只会影响时间的显示,毕竟修改时区不会改变绝对时间,但某些场景中时区就显得举足轻重了。比如“每天零点重新计算用户活跃度”,“每周一上午八点发送推荐邮件”,我们发现如果不指明时区,“零点”和“上午八点”这样的时间是无意义的。在类似的场景中,时区是必须提供的一个信息。在一些国际化产品中,用户来自全世界各地,在查看报表等信息时,用户当然希望显示的是自己所在时区的时间。一种解决方式是在用户表中增加一个字段来保存用户的时区,然后用 in_time_zone 或下面的 around_action 将时间显示为用户所在时区的本地时间。

1
2
3
4
5
6
7
8
9
10
11
12
# http://api.rubyonrails.org/classes/Time.html#method-c-zone-3D
class ApplicationController < ActionController::Base
  around_filter :set_time_zone

  def set_time_zone
    if logged_in?
      Time.use_zone(current_user.time_zone) { yield }
    else
      yield
    end
  end
end

参考

The Exhaustive Guide to Rails Time Zones

It’s About Time (Zones)

http://stackoverflow.com/questions/6118779/how-to-change-default-timezone-for-active-record-in-rails

http://api.rubyonrails.org/classes/Time.html#method-c-zone-3D

尝试 Let’s Encrypt

在计算机网络的远古时期,HTTP 的设计者一开始并没有在安全方面做太多的考虑,他们把 HTTP 设计为一个明文传输的文本协议,简单、直观。HTTP 出现后互联网经历了爆炸性的成长,HTTP 成为了使用最广泛的一种应用层网络协议,这与它简单直观的特点不无关系。然而,在互联网高速发展了几十年后,支撑 HTTP 广泛应用的几个特性却正在慢慢制约互联网的继续发展。其中一个特性,明文传输,越来越不适合存在于当今的互联网环境。明文传输,意味着在数据链路上的任何人都有可能会窥探和篡改数据,在数据往返的途中,路由器、公共 WiFi、电信运营商、GFW 等都有可能造成潜在的危害。当前人们对互联网安全性的要求越来越高,使用加密的传输协议势在必行。

好在有 HTTPS,它在 HTTP 的基础上增加了一层加密的协议,应用层的代码几乎不需要任何修改,只要部署好相关的设施就能无缝迁移到安全的 HTTPS 协议了。但是部署 HTTPS 需要向证书认证机构申请证书,这对个人来说是一件非常繁琐的事情,而且证书通常需要购买,更增加了个人网站使用 HTTPS 的难度。

然而在 2015 年,情况发生了变化,一家新的证书机构 Let’s Encrypt 开始提供免费、自动化的证书服务。它的官网是这么描述自己的:

Let’s Encrypt is a new Certificate Authority:

It’s free, automated, and open.

免费,Let’s Encrypt 提供的证书是完全免费的,完全不需要任何支付费用。自动化的,使用它提供的工具几部操作就可以完成证书的获取,更进一步,还可以把证书的获取、更新、部署通过程序完全自动化。开放的,整个服务开发与更新都是开放的。当然,Let’s Encrypt 提供的证书是安全的。

获取证书

在刚了解 Let’s Encrypt 时,我以为需要到官网注册账号,之后才能获取到证书。后来发现完全不需要,只要证明证书绑定的域名属于你自己就可以了,非常简便。

Let’s Encrypt 提供了工具来实现证书的获取和更新,可以通过 Git 下载:

1
2
3
$ git clone https://github.com/letsencrypt/letsencrypt
$ cd letsencrypt
$ ./letsencrypt-auto --help

运行 letsencrypt-auto 会自动安装依赖包并且把自身升级到最新版,所以第一次执行上面最后一条命令会有一点慢。执行过命令后会创建 /etc/letsencrypt/ 目录,证书以及其他的数据都会保存在这个目录,因此执行命令需要有 root 权限。

letsencrypt-auto 把依赖安装和升级都做完后就可以使用了。letsencrypt-auto 提供了好几种方式来获取证书,下面是 letsencrypt-auto --help 输出的一部分,描述了提供的几个子命令。

1
2
3
4
5
6
7
8
  (default) run        Obtain & install a cert in your current webserver
  certonly             Obtain cert, but do not install it (aka "auth")
  install              Install a previously obtained cert in a server
  renew                Renew previously obtained certs that are near expiry
  revoke               Revoke a previously obtained certificate
  rollback             Rollback server configuration changes made during install
  config_changes       Show changes made to server config during installation
  plugins              Display information about installed plugins

certonly 子命令只用来获取证书,证书的安装,也就是在 Web Server 里的配置需要手动完成。renew 更新已经获取到的证书,获取到的证书只有 90 天有效期,通过这个命令可以将证书的剩余有效期更新为 90 天。

letsencrypt-auto 使用插件机制实现证书的获取和安装,下面介绍自带的三种插件。

Apache

letsencrypt-auto 提供了针对 Apache 的自动获取和安装插件,只要一条命令 letsencrypt-auto --apache 就完成获取和安装,但是我使用的并不是 Apache,因此无法验证这种方法。

Webroot

letsencrypt-auto 提供的第二种获取证书的插件叫 Webroot。这种方式需要配置服务器上已运行的 Web Server 来实现。比如 Nginx,需要在配置文件里添加下面的配置,使得 /.well-known/* 这样的路径可以被访问到。修改好配置文件后不要忘记 restartreload Nginx。

1
2
3
    location ~ /.well-known {
            allow all;
    }

第二步,需要搞清楚 Web Server 提供静态文件的根路径。基本是对应 Nginx root 指令指定的位置,比如 /usr/share/nginx/html/var/www/,需要根据具体的配置文件来确定,我们假设这个路径叫 $WEBROOT_PATH,同时假设证书绑定的域名叫 $DOMAIN$WWW_DOMAIN

然后执行下面的命令就可以获取到证书了。证书以及其他数据存储在 /etc/letsencrypt/ 目录。

1
./letsencrypt-auto certonly --webroot --webroot-path $WEBROOT_PATH -d $DOMAIN -d $WWW_DOMAIN

Webroot 插件的原理是配置已有的 Web Server,使 /.well-known/* 这样的路径可以从外部访问,同时在 Web Server 的根路径放一个 .well-known/xxx... 文件。这个文件是在执行上面的命令时生成的,然后把访问路径告诉 Let’s Encrypt 的服务器,Let’s Encrypt 服务器访问后就可以验证待绑定域名的有效性。所以在执行完上面的命令后,Web Server 的日志里应该会有几条类似这样的访问记录。

1
"GET /.well-known/acme-challenge/kiEt5Rn8rAQqZBQ9-ve6-xxxxxx HTTP/1.1" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)"

Standalone

Webroot 需要配置已有的 Web Server,步骤稍显繁琐,standalone 插件可以把这些事情自动化,但前提是如果已有 Web Server 在监听 80 端口,需要暂时停掉,否则 standalone 插件不能启动自带的服务器程序。获取到的证书同样存储在 /etc/letsencrypt/

1
./letsencrypt-auto certonly --standalone -d $DOMAIN -d $WWW_DOMAIN

使用证书配置 HTTPS

配置 HTTPS 已经与 Let’s Encrypt 没有关系了。比如 Nginx 可以使用下面的片段配置 HTTPS,其中的域名需要修改成自己的。

1
2
3
4
5
6
    listen 443 ssl;

    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

续签证书

上面提到过,获取到的证书有效期只有 90 天,因此需要在过期之前续签,将证书有效期重新设置为 90 天。使用 renew 子命令实现续签。

1
./letsencrypt-auto renew

如果想要实现自动续签,只要在一定时间间隔内重新执行上面的命令就可以了。网上的大部分教程使用 Linux 的 Cron 实现自动续签。

参考

Homepage

Getting Started

Configure Nginx on Ubuntu 14.04

Promise in JavaScript

Promise

JavaScript 的并发编程模型是依赖回调函数的异步事件驱动模型。这种模型通过只使用单线程避免了多线程模型的诸多弊端,同时还可以充分利用单核 CPU,达到不错的性能表现。但是如果业务逻辑比较复杂,大量的异步回调函数会使代码非常难读难调试。Promise 是对回调函数的进一步抽象,力图在保留原有优势的情况下解决回调函数带来的问题。

Promise in jQuery

jQuery 可能是使用最多的 JavaScript 库,jQuery 的 Ajax 部分显然是异步的、依赖回调函数的,自然也就有上面说的各种问题。jQuery 从 1.5 版本开始引入了 Deferred 对象,Deferred 对象和相关的 Promise 对象是 jQuery 对 Promise 的实现。Deferred 包含两类方法,一类是修改内部状态的,一类是注册回调函数的,Promise 隐藏了 Deffered 的修改内部状态的方法,只保留了注册回调函数的接口。jQuery 1.5 的 Ajax 全部使用 Deferred 对象重写了,所以很多人可能已经通过 Ajax 操作使用过 Deferred 对象了。下面的例子是 Ajax 的两种写法:

1
2
3
4
5
6
7
8
9
10
// callback
$.ajax({
  url: '/foo',
  success: function(data) {
    console.log(data);
  },
  error: function() {
    console.error('error');
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Promise object
xhr = $.ajax({
  url: '/foo'
});

xhr.done(function(data) {
  console.log(data);
});

xhr.fail(function() {
  console.error('error');
});

// chainable Promise object
$.ajax({
  url: '/foo'
}).done(function(data) {
  console.log(data);
}).fail(function() {
  console.error('error');
});

jQuery.ajax 的返回值是一个 Promise 对象,donefail 都是 Promise 对象的方法,而且返回值还是 Promise 对象自身。两种写法看上去好像差不多,done 对应 successfail 对应 error。但是 Promise 对象的写法可以把把回调单独拿出来写,同时又因为可以使用链式写法,可读性上貌似好了一些。但是使用 Promise 的写法还有其他好处。

首先,Promise 对象的 donefail 方法可以调用多次,也就是可以自由关联多个回调函数,传统写法如果想关联多个回调,只能把多个函数用一个函数包裹后才可以。

其次,我认为是最重要的,Promise 对象可以解耦事件注册和事件触发的顺序。在传统的 JavaScript 事件编程中,事件注册一定要在事件触发之前,否则可能会漏掉事件。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// correct
var httpRequest = new XMLHttpRequest();

httpRequest.onreadystatechange = function() {
  console.log(arguments);
};

httpRequest.open('GET', '/foo/bar', true);
httpRequest.send(null);


// wrong!
var httpRequest = new XMLHttpRequest();

httpRequest.open('GET', '/foo/bar', true);
httpRequest.send(null);

httpRequest.onreadystatechange = function() {
  console.log(arguments);
};

第二段代码是错误的,在 send 之后再使用 onreadystatechange 注册回调函数有可能会漏掉一些事件。jQuery 的 ajax 函数通过把回调限制为参数的一部分来避免这个问题。

Promise 则不一定需要保证这样的顺序,因为 Promise 对象内部保存了事件的状态,所以即使注册回调函数发生在事件触发之后,注册的回调函数仍然可以执行。下面的例子演示了这个过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var slow_func = function() {
  for (var i = 0; i < 1000000; i++) {
    1 + 1;
  }
};

xhr = $.ajax({
  url: '/foo'
});

slow_func();

// xhr completed here!

slow_func();


xhr.done(function(data) {
  // still executed!
  console.log(data);
});

TBC Promise in ES2015

Active Job 和 Sidekiq 简介

Active Job

Rails 4.2 引入了异步任务框架 Active Job。在 Active Job 出现之前,已经有许多与 Rails 配套的异步任务框架了,比如 Resque、Sidekiq 等。Rails 官方引入 Active Job 的目的并不是取代已有的异步任务框架,而是构建一层通用的异步任务接口,通过适配器对接不同的后端。这样,异步任务使用 Active Job 提供的接口编写,后端可以使用不同的框架执行,有点类似 ActiveRecord 与 MySQL、PostgreSQL 的关系。

下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
# app/jobs/guests_cleanup_job.rb

class GuestsCleanupJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    puts 'start guests cleanup...'
    sleep(10)
    puts 'stop guests cleanup.'
  end
end

Active Job 与 Sidekiq 集成

根据 Rails Guide 中 Active Job Basics 的说明,如果没有设置适配器,任务会立即执行。因此,想要实现任务的异步执行,必须集成一个后端。因为我比较熟悉 Sidekiq,所以选择 Sidekiq 作为后端。

首先在配置中指定使用的后端适配器:

1
2
3
4
5
6
7
8
# config/application.rb
module YourApp
  class Application < Rails::Application
    # Be sure to have the adapter's gem in your Gemfile and follow
    # the adapter's specific installation and deployment instructions.
    config.active_job.queue_adapter = :sidekiq
  end
end

正如上面注释中写的,Active Job 只提供了设置后端适配器的选项,其余的设置都要参考 Sidekiq 本身的配置了。首先在 Gemfile 中加入 Sidekiq:

1
gem 'sidekiq'

执行 bundle 后就可以用 bundle exec sidekiq 启动 Sidekiq 了。

Sidekiq 配置

Web UI

Sidekiq 有一个 Web 界面,可以比较方便的查看任务状态。首先在 Gemfile 中加入 Sinatra。

1
2
3
4
# Gemfile

# if you require 'sinatra' you get the DSL extended to Object
gem 'sinatra', :require => nil

然后在 config/routes.rb 中挂载到一个地址,其中 require 'sidekiq/web' 是必需的。

1
2
3
4
5
6
7
8
# config/routes.rb

Rails.application.routes.draw do
  # ...
  require 'sidekiq/web'
  mount Sidekiq::Web, at: '/sidekiq'
  # ...
end

bundle 后重启 Rails 服务器就可以在 localhost:3000/sidekiq 看到 Sidekiq 的 Web 界面了。

Redis 设置

Sidekiq 依靠 Redis 实现异步功能,可以对 Redis 做一些设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/initializers/sidekiq.rb

url = 'redis://localhost:6379/1'
redis_config = {
  url: url,
  namespace: 'sidekiq',
}

Sidekiq.configure_server do |config|
  config.redis = redis_config
end

Sidekiq.configure_client do |config|
  config.redis = redis_config
end

上面的配置指定了 Sidekiq 连接 Redis 使用的 URL 以及创建 key 时的前缀。URL 除了主机名和端口号,还可以指定一个数据库号,这个是 Redis 提供的数据隔离的一种方式。比如开发环境使用 0 号数据库,而测试环境使用 1 号数据库。namespace 也是数据隔离的一种方式,依赖 redis-namespacegem 实现,通过在 key 加上相同的前缀可以方便查看哪些 key 是 Sidekiq 创建和使用的。下面是 redis-cli 的输出。

1
2
3
4
5
6
7
8
% redis-cli
127.0.0.1:6379> SELECT 1
OK
127.0.0.1:6379[1]> KEYS *
1) "sidekiq:stat:processed"
2) "sidekiq:stat:processed:2015-10-05"
3) "sidekiq:queues"
127.0.0.1:6379[1]>

上面的配置中出现了 configure_serverconfigure_client 两部分,是因为 Sidekiq 是 C/S 架构的。从 Redis 的角度看,向 Redis 队列中增加任务的是客户端,通常是 Puma、Unicorn 等 Rails 进程,从 Redis 队列中取任务的是服务端,一般就是 bundle exec sidekiq 启动的进程。

sidekiq.yml

sidekiq 命令有许多选项可以设置,下面是 bundle exec sidekiq -h 的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
% bundle exec sidekiq -h
2015-10-05T14:10:11.607Z 29897 TID-ovozeltdo INFO: sidekiq [options]
    -c, --concurrency INT            processor threads to use
    -d, --daemon                     Daemonize process
    -e, --environment ENV            Application environment
    -g, --tag TAG                    Process tag for procline
    -i, --index INT                  unique process index on this machine
    -q, --queue QUEUE[,WEIGHT]       Queues to process with optional weights
    -r, --require [PATH|DIR]         Location of Rails application with workers or file to require
    -t, --timeout NUM                Shutdown timeout
    -v, --verbose                    Print more verbose output
    -C, --config PATH                path to YAML config file
    -L, --logfile PATH               path to writable logfile
    -P, --pidfile PATH               path to pidfile
    -V, --version                    Print version and exit
    -h, --help                       Show help

这些选项同时也可以写到 config/sidekiq.yml 配置文件中,比如:

1
2
3
4
5
6
7
# config/sidekiq.yml

---
:daemon: true
:verbose: true
:pidfile: ./tmp/pids/sidekiq.pid
:logfile: ./log/sidekiq.log

上面的配置文件告诉 Sidekiq 以守护进程方式运行,输出更多日志,pid 文件和日志文件的路径。

结束 Sidekiq 守护进程

如果以守护进程方式运行,除了用 pskill 命令结束进程,还可以用 Sidekiq 提供的 sidekiqctl 命令。

1
2
% sidekiqctl stop tmp/pids/sidekiq.pid
Sidekiq shut down gracefully.

sidekiqctl 还有一个子命令 quiet,参考 Sidekiq signals

日志

ActiveJob#logger 方法是 Active Job 提供的记录日志的方法,默认与其他 Rails 日志记录在同一个文件,如果想让 Sidekiq 进程的日志记录到单独的文件,可以使用 Sidekiq 的 logger Sidekiq::Logging.logger

1
2
3
4
5
6
7
8
9
10
11
class GuestsCleanupJob < ActiveJob::Base
  queue_as :default

  self.logger = Sidekiq::Logging.logger

  def perform(*args)
    logger.debug 'start guests cleanup...'
    sleep(10)
    logger.debug 'stop guests cleanup.'
  end
end

参考

Active Job

Active Job guide

Sidekiq

Sidekiq client server

Sidekiq signals

设置 MiniMagick 查找命令的路径

MiniMagick 是 Ruby 中常用的图片处理库,它是基于 ImageMagick 这个跨平台图片处理库实现的。比较特别的是,MiniMagick 是通过调用 ImageMagick 提供的一系列命令来实现图片处理功能的,也就是只要操作系统中有对应的 ImageMagick 命令,比如 identifyconvert ,MiniMagick 就能正常工作。

只要涉及到命令调用,就一定会有命令查找路径的问题。MiniMagick 默认只使用命令名来调用命令,也就是从环境变量的 PATH 中查找。这一般没有问题,但如果 ImageMagick 的可执行程序没有安装在默认的 PATH 中,那么可以通过 MiniMagick.cli_path= 这个方法来指定命令查找的路径。例如在 Rails 中,可以创建一个 config/initializers/mini_magick.rb 初始化文件,写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
# config/initializers/mini_magick.rb

MiniMagick.configure do |config|
  # 如果不能正常调用 ImageMagick 的命令,则使用指定的路径。
  begin
    # 返回命令 `identify -version` 输出,如果查找不到命令则会抛出异常。
    MiniMagick.cli_version
  rescue
    config.cli_path= '/root/.linuxbrew/bin'
  end
end

上面的初始化文件首先尝试调用 ImageMagick 的命令,如果调用失败则使用指定的路径查找命令。

Bootstrap 输入框效果实现

当我第一次用 Bootstrap 时,印象最深的是加在表单的文本输入框上的效果。浅灰的圆角边框,似有似无的边框阴影,以及获得焦点时的蓝色边框效果。但是一直以来我并不知道这些效果是如何实现的,最近终于下决心研究一下 Bootstrap,看看它是如何写成的。首先想到的便是输入框的效果。

我是用 bootstrap-sass 这个 Ruby 的 gem 研究 Bootstrap 源码的。与输入框相关的代码在 assets/stylesheets/bootstrap/_forms.scss

查看网页的源码发现,实现这些输入框效果的是 .form-control 这个 class。.form-control 的源码如下:

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
34
35
36
37
38
39
40
.form-control {
  display: block;
  width: 100%;
  height: $input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)
  padding: $padding-base-vertical $padding-base-horizontal;
  font-size: $font-size-base;
  line-height: $line-height-base;
  color: $input-color;
  background-color: $input-bg;
  background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
  border: 1px solid $input-border;
  border-radius: $input-border-radius; // Note: This has no effect on <select>s in some browsers, due to the limited stylability of <select>s in CSS.
  @include box-shadow(inset 0 1px 1px rgba(0,0,0,.075));
  @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s);

  // Customize the `:focus` state to imitate native WebKit styles.
  @include form-control-focus;

  // Placeholder
  @include placeholder;

  // Disabled and read-only inputs
  //
  // HTML5 says that controls under a fieldset > legend:first-child won't be
  // disabled if the fieldset is disabled. Due to implementation difficulty, we
  // don't honor that edge case; we style them as disabled anyway.
  &[disabled],
  &[readonly],
  fieldset[disabled] & {
    background-color: $input-bg-disabled;
    opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655
  }

  &[disabled],
  fieldset[disabled] & {
    cursor: $cursor-disabled;
  }

  // [converter] extracted textarea& to textarea.form-control
}

圆角用 border-radius 属性实现,边框颜色是在 border 属性中设置的。最重要的是下面两行:

1
2
@include box-shadow(inset 0 1px 1px rgba(0,0,0,.075));
@include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s);

用到了 Sass 的 Mixin 功能,实际的实现就是设置 box-shadowtransition 属性。也就是说,输入框的效果主要是靠这两个属性实现的。

box-shadow

box-shadow 用来设置盒阴影。

transition

transition 用来设置过渡效果。

TODO uncompleted

Explore RubyGems 2

What is a gem

gem 是一种 Ruby 软件包,每个 gem 都有一个名字、版本和平台。名字和版本好理解,平台指的是 CPU 架构、操作系统及版本等信息。

gem 的目录结构

gem 的目录结构遵循一些约定,如下:

1
2
3
4
5
6
7
8
9
10
11
% tree freewill
freewill/
├── bin/
│   └── freewill
├── lib/
│   └── freewill.rb
├── test/
│   └── test_freewill.rb
├── README
├── Rakefile
└── freewill.gemspec

lib 目录里是 gem 的源代码。除此之外,最重要的就是 .gemspec 文件。.gemspec 文件包含了构建一个 gem 的所有的信息,包括 gem 的名字、版本、平台、包含的文件等。

Make Your Own Gem

构建一个 gem 可能是理解 gem 的最好方法。下面会从零开始创建一个示例 gem,我给这个 gem 取名为 factory_boy

首先新建一个目录,下面所有的工作都在该目录内完成。

1
2
% mkdir factory_boy/
% cd factory_boy

一个 gem 肯定要包含一些代码的,按照约定,这些代码应该放到 lib 目录中,而且 lib 目录中应该有一个和 gem 同名的 .rb 文件。

1
2
3
4
5
6
% cat lib/factory_boy.rb
module FactoryBoy
  def self.hi
    puts "Hello, I'm a factory boy."
  end
end

好,现在 gem 的代码已经有了,还需要一个 .gemspec 文件,把它命名为 factory_boy.gemspec

factory_boy.gemspec
1
2
3
4
5
6
7
8
9
10
11
12
Gem::Specification.new do |spec|
  spec.name        = 'factory_boy'
  spec.version     = '0.0.1'
  spec.date        = '2015-05-16'
  spec.summary     = "I am a factory boy."
  spec.description = "A factory boy want to meet a factory girl."
  spec.authors     = ["Factory Boss"]
  spec.email       = 'fb@example.com'
  spec.files       = ["lib/factory_boy.rb"]
  spec.homepage    = 'http://fb.example.com'
  spec.license     = 'MIT'
end

factory_boy.gemspec 文件描述了该 gem 的许多信息,比较重要的是通过 spec.files = ... 指定 gem 包含的文件,如果没有指定该项,构建出的 gem 是一个不包含任何文件的空 gem。

现在就可以构建一个最简单的 gem 了, gem build 命令可以方便的完成这个操作,将对应的 .gemspec 文件作为参数即可。

1
2
3
4
5
% gem build ./factory_boy.gemspec
  Successfully built RubyGem
  Name: factory_boy
  Version: 0.0.1
  File: factory_boy-0.0.1.gem

gem build 命令会将构建好的 gem 放在当前目录,文件名中包含了 gem 的名字和版本。要测试这个 gem 是否能正常工作,需要先安装,然后就可以用 irb 或其他方式测试了。

1
2
3
4
5
6
7
8
9
10
11
% gem install ./factory_boy-0.0.1.gem
Successfully installed factory_boy-0.0.1
1 gem installed


% irb
irb(main):001:0> require 'factory_boy'
=> true
irb(main):002:0> FactoryBoy.hi
Hello, I'm a factory boy.
=> nil

看上去不错,如果想要将自己编写的 gem 发布到 rubygems.org 上,需要用到 gem push 命令。

更常见的目录结构

通常一个 gem 会包含多个文件,推荐的做法是在 lib 目录下建立一个与 gem 同名的目录,文件都放到该目录内。使用 require 时会在 lib 目录搜索文件,所以一般 lib 目录下只有一个与 gem 同名的 .rb 文件。我们可以把刚才的 gem 改成如下的目录结构:

1
2
3
4
5
6
% tree lib
lib
├── factory_boy
│   ├── hi.rb
│   └── version.rb
└── factory_boy.rb

文件内容改为如下:

factory_boy/hi.rb
1
2
3
4
5
module FactoryBoy
  def self.hi
    puts "Hello, I'm a factory boy."
  end
end
factory_boy/version.rb
1
2
3
module FactoryBoy
  VERSION = '0.0.2'
end
factory_boy.rb
1
2
3
4
5
require 'factory_boy/hi'
require 'factory_boy/version'

module FactoryBoy
end

把 gem 的实现都放在 lib/factory_boy 目录中,然后在 factory_boy.rb 中引入这些文件。注意 factory_boy.rb 中的 require 方法,RubyGems 会正确设置 $LOAD_PATH,确保能找到 lib/factory_boy 目录下的文件。

改好之后需要再次测试一下,这次换另一种测试方法,不需要安装这个 gem。当然在这之前需要用 gem uninstall factory_boy 卸载之前安装的 gem。

1
2
3
4
5
6
% irb -Ilib -rfactory_boy
irb(main):001:0> FactoryBoy::VERSION
=> "0.0.2"
irb(main):002:0> FactoryBoy.hi
Hello, I'm a factory boy.
=> nil

上面的 irb 命令会将 lib 目录加入 $LOAD_PATH,然后执行 require 'factory_boy',因此进入 irb 就可以直接使用 FactoryBoy 这个模块了。

测试看上去没有问题,这时可以构建新的 gem 了。不过构建之前需要对 .gemspec 文件做一些修改,因为增加了新的源文件,所以 spec.files 这一项必须要修改。每次增加或删除文件都要修改 .gemspec 文件,不仅麻烦,而且容易忘记修改。使用 Dir.[] 方法是一个不错的选择。修改后的 .gemspec 文件如下:

factory_boy.gemspec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'factory_boy/version'

Gem::Specification.new do |spec|
  spec.name        = 'factory_boy'
  spec.version     = FactoryBoy::VERSION
  spec.date        = '2015-05-16'
  spec.summary     = "I am a factory boy."
  spec.description = "A factory boy want to meet a factory girl."
  spec.authors     = ["Factory Boss"]
  spec.email       = 'fb@example.com'
  spec.files       = Dir['lib/**/*']
  spec.homepage    = 'http://fb.example.com'
  spec.license     = 'MIT'
end

主要有两方面的修改,第一,使用 Dir['lib/**/*'] 获得 lib 目录下的所有文件,这样增加或删除文件时就不需要修改 .gemspec 文件了。第二,gem 的版本通过 factory_boy/version.rb 文件获得,这样保证修改版本时只需修改一处代码即可,满足 DRY 的原则,而且这也是多数 gem 采用的方式。

增加可执行文件

gem 除了能提供 Ruby 代码,还可以提供一个或多个可执行文件。比如常见的 rake bundle rails 命令都是对应的 gem 提供的可执行文件。向一个 gem 中添加可执行文件非常简单,只要在 gem 目录中创建可执行文件,然后在 .gemspec 文件中声明就好了。构建 gem 时,会从 spec.bindir 对应的目录中查找 spec.executables 中对应的文件作为可执行文件。 spec.bindir 的默认值是 bin ,但是 Bundler 1.8 建议改为 exe,因为 bin 目录是存放 Bundler 的 binstubs 的目录。下面的例子遵循 Bundler 的建议,创建 exe/factory_boy 文件作为可执行文件,同时修改 .gemspec 文件。

exe/factory_boy
1
2
3
4
5
#!/usr/bin/env ruby

require 'factory_boy'

FactoryBoy.hi
factory_boy.gemspec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'factory_boy/version'

Gem::Specification.new do |spec|
  spec.name        = 'factory_boy'
  spec.version     = FactoryBoy::VERSION
  spec.date        = '2015-05-16'
  spec.summary     = "I am a factory boy."
  spec.description = "A factory boy want to meet a factory girl."
  spec.authors     = ["Factory Boss"]
  spec.email       = 'fb@example.com'
  spec.files       = Dir['lib/**/*']
  spec.bindir      = 'exe'
  spec.executables = ['factory_boy']
  spec.homepage    = 'http://fb.example.com'
  spec.license     = 'MIT'
end

经过这样的修改,gem build 构建出的 gem 就包含了可执行文件,在安装后就可以在命令行中使用了。

1
2
3
4
% gem build factory_boy.gemspec
% gem install ./factory_boy-0.0.2.gem
% factory_boy
Hello, I'm a factory boy.

增加依赖 gem

gem 之间是有依赖关系的,如果自己编写的 gem 需要依赖其他 gem,只需在 .gemspec 文件中声明依赖的 gem 名称和版本即可。

1
  spec.add_dependency 'factory_girl', '~> 4.5'

这样在构建 gem 时依赖关系会保存在 gem 包中,安装时会保证依赖的 gem 已经安装。

gem 包的文件格式

gem 包其实是一个 tar 包,用下面的命令可以查看。

1
2
% file factory_boy-0.0.2.gem
factory_boy-0.0.2.gem: POSIX tar archive

既然是 tar 包,就可以展开查看一下里面的内容。

1
2
3
4
5
6
7
% mkdir tmp
% cd tmp

% tar -xvf ../factory_boy-0.0.2.gem
x metadata.gz
x data.tar.gz
x checksums.yaml.gz

可以看到,tar 包里面有三个文件,这三个文件都是用 gzip 压缩过的,其中 checksum.yaml.gz 的内容是另外两个压缩文件的校验和,metadata.gz 的内容是该 gem 的一些元信息,包括 gem 名称、版本、依赖关系等。data.tar.gz 是所有 gem 的文件,安装 gem 时会把这些文件安装到对应路径。

参考

make_your_own_gem

bundler_moves_bins_to_exe

Explore RubyGems 1

最近对 Ruby 的常用工具的使用以及实现原理产生了兴趣,因为前一阵子发布了一个试验性质的 gem,所以对 gem 相关的东西学习了一下。寻根溯源,找到了一个叫做 RubyGems 的概念。

Basics

RubyGems 是什么?这几个字母的组合会在许多地方出现,Gemfile 的第一行一般是 source 'https://rubygems.org' ,对应有一个域名为 rubygems.org 的网站,在一些很多年前的 Ruby 代码中会有 require 'rubygems' 。基本可以确定 rubygems.org 是 RubyGems 的官网,那就从官网入手吧。下面是官网教程的说法:

The RubyGems software allows you to easily download, install, and use ruby software packages on your system. The software package is called a “gem” and contains a package Ruby application or library.

所以 RubyGems 是一套软件,这套软件的目标是让用户方便的下载、安装和使用 Ruby 的软件包。同时从这段话中可以看到,在 RubyGems 的范畴内,Ruby 的软件包叫做 gem。当然,有一些 Ruby 使用经验的人都知道还有一个叫 gem 的命令,这个命令当然也是 RubyGems 提供的。gem 命令是 RubyGems 提供的使用接口。RubyGems 需要一个中心服务器存储管理所有的 gem,默认使用的服务器地址就是 https://rubygems.org 。

被重写的 Kernel#require 方法

Kernel#require 方法是用来干什么的?用来加载 Ruby 代码,它会从全局变量 $LOAD_PATH 中查找对应名字的文件并加载。然而上面的描述在 RubyGems 存在的情况下并不准确。RubyGems 重写了 Kernel#require 方法,扩展了它的功能。

kernel_require.rblink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  ##
  # When RubyGems is required, Kernel#require is replaced with our own which
  # is capable of loading gems on demand.
  #
  # When you call <tt>require 'x'</tt>, this is what happens:
  # * If the file can be loaded from the existing Ruby loadpath, it
  #   is.
  # * Otherwise, installed gems are searched for a file that matches.
  #   If it's found in gem 'y', that gem is activated (added to the
  #   loadpath).
  #
  # The normal <tt>require</tt> functionality of returning false if
  # that file has already been loaded is preserved.

  def require path
    # ...
  end

从注释中可以看到,重写后的 Kernel#require 引入文件时会分两步:

  1. 如果文件能在 $LOAD_PATH 中找到,那就引入它。
  2. 否则,在所有已安装的 gem 中查找,如果找到,那么把对应的 gem 激活,也就是把该 gem 的 lib 目录加入 $LOAD_PATH。

需要注意的是,require 的参数永远都对应一个文件名(可以省略 .rb 扩展名),而不是 gem 的名字。比如 awesome_print 这个 gem 的 lib 目录是这样的:

1
2
3
4
5
6
7
% tree -L 1 lib                                                              ➜
lib
├── ap.rb
├── awesome_print
└── awesome_print.rb

1 directory, 2 files

那么可以用下面的方式引入:

1
2
3
4
require 'ap'
require 'ap.rb'
require 'awesome_print'
require 'awesome_print.rb'

个人猜测,如果在其他 gem 中有同名的文件,可能会导致错误的引入。为了避免这样的问题,RubyGems 给出了一些命名和目录结构的建议。

概念

  • RubyGems,软件包管理软件
  • gem,RubyGems 中的软件包
  • gem,RubyGems 提供的命令行接口
  • https://rubygems.org ,RubyGems 的一个中心服务器

参考

guides

requiring_code