MZhou's blog

GA源代码里的小技巧之cookie篇

作者前段时间在做类似Google Analytics(以下简称GA)的第三方监控脚本。所以对GA的前端代码做过调研,对GA的压缩后代码做了一定程度上的人肉美化。这里美化的是analytics.js的j41版本,本文提到的小技巧也是基于这个版本的js。

cookie的本质是存储在浏览器端的一段简单数据(多个键值对),浏览器会从服务器接受或者发送给服务器cookie。这样便可以为没有状态的HTTP协议提供了记录状态信息的方法,知道多个不同的HTTP请求是否来自同一个浏览器。

在浏览器中cookie是支持范围最广的存储数据的手段了,前端工程师一般都曾经或多或少的使用过cookie来存储数据或变量。浏览器提供了document.cookie这个接口来增删改查cookie。但是只能一次设置一个cookie。使用方法如下:

// 设置名为key的cookie,值为value
document.cookie = 'key=value';

代码中省略了其他可选配置,具体使用方法可以参考MDN文档。通过domainpath参数,可以将cookie设置在不同的域名或者路径下,例如:

// 设置名为key的cookie,值为value
document.cookie = 'A=1;path=/;domain=map.baidu.com;';

上面的代码在域名map.baidu.com下的根路径(/)设置了cookie A的值为1。我们可以通过document.cookie来获取当前域名和路径下的所有cookie。当我们访问http://map.baidu.com页面时,执行document.cookie获得的结果是A=1

大家知道域名是有父域名和子域名的区别的,键名相同的两个cookie可以分别设置在父子域名上。例如如下代码:

document.cookie = 'A=1;path=/;domain=.example.com;';
document.cookie = 'A=2;path=/;domain=a.example.com;';

顺带提到一个冷门的知识点:以.开头的域名表示cookie设置在此域名及其子域上,否则不适用于其子域名。不过从RFC 2965标准开始浏览器便会自动为domain属性值自动添加一个.前缀,所以在设置domain时加不加.前缀已经没有区别了。但是为了兼容一些旧浏览还是加.为好。另外如果不设置domain浏览器会默认用当前页面的域名,这时浏览器不会自动添加.前缀,自然也就不会包含子域了。

.example.coma.example.com域名下面分别设置了A=1A=2两个cookie。此时我们在http://a.example.com页面下执行document.cookie得到的结果是:A=1;A=2。如下图:

2

开发者是没办法从结果中知道这个cookie是设置在哪个域名上的。同样这个情况也适用于不同的父子路径上。

这在一般情况下对开发者不会有影响,但是对于GA来说确实致命的。GA会在当前网站域名下面设置一个全局唯一的cookie _ga,用于标志相同的用户。现在大型的公司都会分不同的网站域名,例如:baidu.comditu.baidu.com。假设百度使用了GA~ 那么GA会分别在两个域名下设置不同的_ga cookie值,这样在baidu.com下GA便会拿到两个_ga值。不知道该用哪个,傻傻分不清楚。

为了解决这个问题,GA在cookie的值上面做文章。可以看到冲突只会发生在父子域名和父子路径上。因为cookie本身的特殊性:所有http请求会带着该域名下的所有cookie。如果cookie值太长太多会消耗太多带宽。GA通过计算域名和路径的“长度”来唯一标示这个cookie所设置在的域名和路径。计算“长度”的方法如下:

/**
 * normalize domain.
 * remove the first '.' if exist.
 * @param {string} domain domain String
 * @return {string} normalized domain
 */
var normalizeDomain = function (domain) {
    return domain.indexOf('.') === 0 ? domain.substr(1) : domain;
};

/**
 * get count of domain.
 * getDomainCount('qq.com') === 2
 * getDomainCount('.qq.com') === 2
 * getDomainCount('b.qq.com') === 3
 * getDomainCount('e.qidian.qq.com') === 4
 *
 * @param {string} domain domain String
 * @return {string} normalized domain
 */
var getDomainCount = function (domain) {
    return normalizeDomain(domain).split('.').length;
};

/**
 * normalize path
 * normalizePath('') === '/'
 * normalizePath('/') === '/'
 * normalizePath('/ping') === '/ping'
 * normalizePath('/ping/pv') === '/ping/pv'
 * normalizePath('ping/pv') === '/ping/pv'
 * normalizePath('ping/pv/') === '/ping/pv'
 *
 * @param {string} path path
 * @return {string} path
 */
var normalizePath = function (path) {
    if (!path) {
        return '/';
    }

    if (path.length > 1 && path.lastIndexOf('/') === path.length - 1) {
        // remove last '/'
        path = path.substr(0, path.length - 1);
    }

    if (path.indexOf('/') !== 0) {
        // fill up first '/'
        path = '/' + path;
    }

    return path;
};

/**
 * get count of path
 * getPathCount('') === 1
 * getPathCount('/') === 1
 * getPathCount('/ping') === 2
 * getPathCount('/ping/pv') === 3
 * getPathCount('ping/pv') === 3
 * getPathCount('ping/pv/') === 3
 *
 * @param {string} path path
 * @return {number} count of path
 */
var getPathCount = function (path) {
    path = normalizePath(path);
    return path === '/' ? 1 : path.split('/').length;
};

/**
 * Get domain and path count string.
 *      domainCount + '-' + pathCount + '$'
 *
 * @param {Window=} win window context.
 * @param {string=} domain cookie domain.
 * @param {string=} path cookie path.
 * @return {string} count string.
 */
var getDomainAndPathCount = function (win, domain, path) {
    win = win || window;

    var location = win.location;
    var pathCount = getPathCount(path != null ? path : location.pathname);
    var domainCount = getDomainCount(domain != null ? domain : location.hostname);
    return domainCount + (pathCount > 1 ? '-' + pathCount : '') + '-';
};

GA在设置cookie时加上domainpath的长度前缀,然后在取出cookie时遍历所有的名字相同的cookie找到指定域名和路径下的cookie。示例代码如下:

/**
 * Set cookie.
 * @param {string} key cookie name.
 * @param {string|number} value cookie value.
 * @param {Window=} win window context.
 * @param {number=} expires cookie expired time in milliseconds.
 * @param {string=} domain cookie domain.
 * @param {string=} path cookie path.
 * @return {boolean} success or not.
 */
var setCookie = function (key, value, win, expires, domain, path) {
    var domainAndPathCount = getDomainAndPathCount(win, domain, path);
    value = value + '';
    return setCookieRaw(key, domainAndPathCount + value.replace(/\-/g, '%2d'), win, expires, domain, path);
};
/**
 * Get cookie value.
 *
 * @param {string} key key name.
 * @param {Window=} win window context.
 * @param {string=} domain cookie domain.
 * @param {string=} path cookie path.
 * @return {string} cookie value.
 */
var getCookie = function (key, win, domain, path) {
    var results = getCookieRaw(key, win);
    var domainAndPathCount = getDomainAndPathCount(win, domain, path);
    for (var i = 0; i < results.length; i++) {
        var r = results[i];
        if (r.indexOf(domainAndPathCount) === 0) {
            return r.slice(domainAndPathCount.length).replace(/%2d/g, '-');
        }
    }
    return '';
};

魔鬼👹藏于细节。GA对细节的追求并没有止步于此。cookie名_ga虽然一般很少有开发者会用到,但是不怕一万就怕万一。如果cookie名_ga冲突了并被改写成了别的值,那么GA很可能会发送一个不符合要求的值过去。这对服务器端解析也非常不利。

GA在cookie的值之前又加了前缀GA1.,下次使用这个cookie时都会检查是否带有GA1.前缀。如果不存在前缀则直接覆盖生成新的,如果存在则继续复用原cookie值。

除了防止_ga被开发者覆盖之外,猜想还有一个作用,就是“版本号”。多个GA版本之间如果_ga的cookie值生成算法不一样需要特殊处理,那么可以依据GA1.前缀来判断应该是哪个版本,采取正确的操作。

总结下_ga cookie值的格式如下:

// GA{version}.{domainCount}[-{pathCount}].{randomNumber}.{time}
// path是'/'时
document.cookie = '_ga=GA1.3.494346849.1446193077';
// 没有path时
document.cookie = '_ga=GA1.3-2.494346849.1446193077';

细微之处见真章:+1:

参考文档:

  1. RFC 2109
  2. RFC 2965
  3. RFC 6265
  4. What does the dot prefix in the cookie domain mean?
  5. MDN document.cookie

MZhou's blog - Taste of life.

zmmbreeze / @zhoumm