Connection 是一种 HTTP 通用头,应用于 HTTP 请求与响应首部之中,几乎广泛地存在于每一个可见的连接之中。最常见的取值为 closekeep-alive ,前者表示请求响应完成之后立即关闭连接,后者表示连接不立即关闭,可以期望继续响应下一个请求。

close 很容易理解,关于 keep-alive ,我们知道 HTTP 建立在 TCP 传输层协议之上,而 TCP 的建立需要握手,关闭需要发通知,这些步骤都需要时间,带给 HTTP 的就是请求响应时延。

close 模式请求3个资源的时间线是这样的:

服务器--------------------------------------------
        /   \           /   \            / \
       /     \         /     \          /   \
      /       \       /       \        /     \
浏览器--------------------------------------------

那么 keep-alive 模式就可能就是这样的:

服务器--------------------------------------------
        /   \     /   \     / \
       /     \   /     \   /   \
      /       \ /       \ /     \
浏览器--------------------------------------------

这里忽略了软件的数据处理时延,可以明显的看到节省了 TCP 连接建立时间和连接关闭时间所带来的益处。另外,这种方式也可以避免 TCP 多次经历慢启动过程,其带来的时间受益并没有在图中表现出来。

这样看来,keep-alive 应该一直使用来提高 web 加载性能。

哑代理问题

HTTP 首部可分为两类hop-by-hopend-to-end

  • End-to-end headers which are transmitted to the ultimate
    recipient of a request or response. End-to-end headers in
    responses MUST be stored as part of a cache entry and MUST be
    transmitted in any response formed from a cache entry.

  • Hop-by-hop headers which are meaningful only for a single
    transport-level connection and are not stored by caches or
    forwarded by proxies.

显然 hop-by-hop 是不能缓存不能被代理转发的,下面即 HTTP 1.1 定义的8个 hop-by-hop 首部,其它均属于 end-to-end 首部。

因此理论上 Connection 首部是不能被代理、中继等中间 HTTP 节点转发的,另外根据 RFC2616 的定义:

Connection = “Connection” “:” 1#(connection-token)

connection-token = token

Connection 列举的所有 HTTP 首部都不能被转发,如一个请求头中如下:

GET / HTTP/1.1

Accept: /

Connection:Proxy-Time Non-Header Keep-Alive

Proxy-Time:0810

Keep-Alive: max=5 timeout=120

经过代理后应该被修改为:

GET / HTTP/1.1

Accept: /

即移除 Connection 首部及其列举的其它首部。但这是理想情况,万维网上还工作着无数称之为哑代理的盲中继:它们仅把数据按字节转发,并不理会 HTTP 首部中的意义。在这种情况下,就会出现一些非预期的状况:

    ____       keep-alive      ____      keep-alive       ____
   |    |   =+++++++++++++++- |    |  =+++++++++++++++-  |    |
   |    |      keep-alive     |    |     keep-alive      |    |
   \____/   -+++++++++++++++= \____/  -+++++++++++++++=  \____/

   Browser                     Proxy                     Server

如上图,浏览器向服务器发送带有 Connection:Keep-Alive 的请求,中间经过一个哑代理,从而导致该首部到达服务器。对于服务器来说,代理与浏览器没有什么分别,它认为浏览器(代理)尝试建立 Keep-Alive 连接,便在响应头中发回 Connection:Keep-Alive ,并保持连接开启。哑代理将响应首部原封不动的发回给浏览器,并等待服务器关闭连接。浏览器收到响应数据后立即准备进行下一条请求。此时,浏览器和服务器都认为与对方建立了 Keep-Alive 连接,但是中间的代理确对此一无所知。因此哑代理不认为这条连接上还会有请求,接下来来自浏览器的任何请求都会被忽略。这样,浏览器和服务器都会在代理处挂起,直到一方超时将连接关闭为止。这一过程不仅浪费服务器资源,也使得浏览器响应缓慢或失败。

Proxy-Connection

一个变通的做法即是引入一个叫做 Proxy-Connection 的新首部。在浏览器后面紧跟代理的情况下,会以 Proxy-Connection 首部取代 Connection
首部发送。如果代理不能理解该首部,会透传给服务器,这不会带来任何副作用,服务器仅会将其忽略;如果这个代理足够聪明(有能力支持这种 Keep-Alive 连接),会将 Proxy-Connection 首部替换成 Connection 发送给服务器,从而达到建立双向 Keep-Alive 连接的目的。

我们可以开启 Fiddler 并观察 Chrome 或 IE 开发工具中 Network中的请求头,都会有 Proxy-Connection 。Firefox好像并没有发送这个首部,Safari可能同时发送了 Proxy-ConnectionConnection 首部,Fiddler 没有移除 Connection 首部但将 Proxy-Connection 替换为 Connection ,导致出现两个 Connection 首部。

显然,在聪明代理和哑代理共存的链路上,上面的提到的挂起的问题仍然存在,Proxy-Connection并没有从根本上解决问题。其实 Proxy-Connection 也并非是一个标准的协议首部,任何标准或草案中都没有提到它,仅仅是应用广泛罢了。

持久化连接

HTTP 1.1 已经废弃了使用 Keep-Alive,而以”持久化连接”的概念取代之。与 HTTP 1.0 不同的是,在 HTTP 1.1 中,持久化连接是默认开启的,除非你手动设置 Connection:close。为了避免收到哑代理误转发过来的 Keep-Alive ,HTTP 1.1 代理应当拒绝与所有来自 HTTP 1.0 设备建立持久化连接(实际厂商并非如此)。

管道

HTTP 1.1 还提供了在持久化连接基础上的一个性能优化特性:请求管道。它可以在一条连接上同时发起多个请求而不必等到前面的请求得到响应,降低网络的环回响应时间,如下图:

服务器--------------------------------------------
        /\/\/\/\
       / /\/\/\ \
      / / /\/\ \ \
浏览器--------------------------------------------

但使用请求管道有一些限制:

  • 连接必须是持久的;
  • 服务器必须按照请求的顺序返回;
  • 浏览器必须能应对部分请求失败的情况,并重试;
  • 不能进行非幂等这类可能带来副作用的请求,如 POST 请求,因为无法安全重试。

一些现代的浏览器支持管道但利用尚不广泛,特别是页面使用较多域名的情况下,管道技术更是难以施展。SPDY 技术更进一步,做到了跨域的多路复用,理论上可以让web的加载速度有显著提升,期待该技术的普及。

参考

在需要主子域跨域技术的应用场景中,父 frame 和子 frame 设置相同的 document.domain 是一种特别常用的方式,我们可以看见腾讯公司的页面中很多都会有一句:

document.domain = "qq.com";

qq.com 域页面的登录行为很多都是依赖这种方式与iframe结合来实现的。

事实上,W3C 的 DOM 2 HTML标准document.domain定义为只读的:

domain of type DOMString readonly
The domain name of the server that served the document or null if the server cannot be identified by a domain name.

HTML5 草案 中有关于对 document.domain赋值的内容。

WebkitDocument.idl 源码中对 domain 有这样的定义:

#if defined(LANGUAGE_JAVASCRIPT) && LANGUAGE_JAVASCRIPT
    [TreatNullAs=NullString  SetterRaisesException] attribute DOMString domain;
#else
    readonly attribute DOMString domain;
#endif

这也说明了 domain 设置为“writable” 仅用于页面脚本:即允许主子域脚本进行通信,但不涉及 localStorageindexedDBXMLHttpRequest 的共享。目前市场上主流浏览器都支持 domain可写,可以满足几乎所有主子域脚本通信需求,但在特殊情况下也有些许不同。

所有浏览器都不支持设置 domain 为当前域子域或不完整的超域,比如当前域为 “abc.google.com” 那么设置 domain 为 “www.abc.google.com” 或”ogle.com” 都是不允许的。现在测试各个浏览器在 host 为以下情形下设置不同domain的反应:

  • 为IP地址
  • 单节域名
  • 多节域名

测试 host 为 “google”、“www.google.com”、“10.11.202.231”。由子域名向父域名测试,因此前面的测试不会对后面的测试造成干扰。

UA/host google www.google.com 10.11.202.231
Firefox(Mac/Windows/Android) s.ff m.ff ip.ff
Safari(iOS/Mac/Windows) s.safari m.safari ip.safari
IE6~7 s.ie67 m.ie67 ip.ie67
Chrome(Mac Windows)/IE8~10/Opera(presto内核 Mac/Windows) s.chrome-ie810-opera m.chrome-ie810-opera ip.chrome-ie810-opera
IE(WP8) 无法打开 m.chrome-ie810-opera ip.chrome-ie810-opera

由上表可得出以下结论:

  • Firefox 可以接受带 port 的父域名,但是任意 port 都会被忽略,其它浏览器则会报错;
  • 对于 IP 地址,IE6、IE7 和 Safari 简单地将其当做为域名;
  • 仅 Safari 允许将 domain 设置为最后一节域名。

Safari 以及 国内几乎所有带 webkit 内核的浏览器 使用了一种相对简单的方式,即在字符串层面上新的 domain 是当前 domain 的“父域名”即可,可以从 webkitDocument.cpp 文件的源代码中看出:

void Document::setDomain(const String& newDomain  ExceptionCode& ec)
{
if (SchemeRegistry::isDomainRelaxationForbiddenForURLScheme(securityOrigin()->protocol()))     {
        ec = SECURITY_ERR;
        return;
    }

    // Both NS and IE specify that changing the domain is only allowed when
    // the new domain is a suffix of the old domain.

    // FIXME: We should add logging indicating why a domain was not allowed.

    // If the new domain is the same as the old domain  still call
    // securityOrigin()->setDomainForDOM. This will change the
    // security check behavior. For example  if a page loaded on port 8000
    // assigns its current domain using document.domain  the page will
    // allow other pages loaded on different ports in the same domain that
    // have also assigned to access this page.
    if (equalIgnoringCase(domain()  newDomain)) {
        securityOrigin()->setDomainFromDOM(newDomain);
        return;
    }

    int oldLength = domain().length();
    int newLength = newDomain.length();
    // e.g. newDomain = webkit.org (10) and domain() = www.webkit.org (14)
    if (newLength >= oldLength) {
        ec = SECURITY_ERR;
        return;
    }

    String test = domain();
    // Check that it's a subdomain  not e.g. "ebkit.org"
    if (test[oldLength - newLength - 1] != '.') {
        ec = SECURITY_ERR;
        return;
    }

    // Now test is "webkit.org" from domain()
    // and we check that it's the same thing as newDomain
    test.remove(0  oldLength - newLength);
    if (test != newDomain) {
        ec = SECURITY_ERR;
        return;
    }

    securityOrigin()->setDomainFromDOM(newDomain);
}

因此即使是IP地址或是最后一节 domain 也会被允许设置。Internet Explorer 不开源,但可以猜测其对多节域名进行了最后一节域名限制,在 IE8+ 上增加了IP地址限制。Firefox 在3.0版本增加了此限制。对于单节域名如 http://hello/,所有浏览器都一致性地允许设置,当然,这相当于设置 domain 为自身。

Firefox浏览器忽略 port 的行为初衷不得而知,但可以测试该特性是在3.0版本上增加的。

值得一提的是,chromium 项目对 webkit 进行了一些修改,从而带来了一些新的特性,观察 Document.cpp 文件的源代码:

void Document::setDomain(const String& newDomain  ExceptionState& exceptionState)
{
    if (isSandboxed(SandboxDocumentDomain)) {
        exceptionState.throwSecurityError("Assignment is forbidden for sandboxed iframes.");
        return;
    }

if (SchemeRegistry::isDomainRelaxationForbiddenForURLScheme(securityOrigin()->protocol()))     {
    exceptionState.throwSecurityError("Assignment is forbidden for the '" + securityOrigin()->    protocol() + "' scheme.");
        return;
    }

    if (newDomain.isEmpty()) {
        exceptionState.throwSecurityError("'" + newDomain + "' is an empty domain.");
        return;
    }

OriginAccessEntry::IPAddressSetting ipAddressSetting = settings() && settings()->treatIPAddressAsDomain() ? OriginAccessEntry::TreatIPAddressAsDomain :     OriginAccessEntry::TreatIPAddressAsIPAddress;
OriginAccessEntry accessEntry(securityOrigin()->protocol()  newDomain      OriginAccessEntry::AllowSubdomains  ipAddressSetting);
    OriginAccessEntry::MatchResult result = accessEntry.matchesOrigin(*securityOrigin());
    if (result == OriginAccessEntry::DoesNotMatchOrigin) {
    exceptionState.throwSecurityError("'" + newDomain + "' is not a suffix of '" + domain() + "'.    ");
        return;
    }

    if (result == OriginAccessEntry::MatchesOriginButIsPublicSuffix) {
        exceptionState.throwSecurityError("'" + newDomain + "' is a top-level domain.");
        return;
    }

    securityOrigin()->setDomainFromDOM(newDomain);
    if (m_frame)
        m_frame->script().updateSecurityOrigin(securityOrigin());
}

可见除了支持HTML5的 sandbox 之外,还增加了IP地址检测,因此 Chrome 才不会像Safari那样允许IP地址的一段设置为 domain。特别地,Chrome 还进行了顶级域名检测, chromium 项目会生成一个 effective_tld_names.gperf 文件,提供了很多域名列表,末尾标记不为0的域名将不能设置为 document.domain。比如,一个域名为www.english.uk的页面,在 Chrome 下将不能设置 document.domainenglish.uk,因为 english.uk 被认为是顶级域名从而报错:

SecurityError: Failed to set the ‘domain’ property on ‘Document’: ‘english.uk’ is a top-level domain.

这部分逻辑的一些代码来自 Mozilla ,因此Firefox (3.0+)也具有同样的特性。

截止今天(2014-04-24),这个列表至少包含472个顶级域名,包括孟加拉国、文莱、库克群岛、塞浦路斯、厄立特里亚、埃塞俄比亚、斐济、马尔维纳斯群岛、关岛、以色列、牙买加、肯尼亚、柬埔寨、科威特、缅甸、莫桑比克、尼加拉瓜、尼泊尔、新西兰、巴布亚新几内亚、土耳其、英国、也门、南非、赞比亚、津巴布韦等国家和地区的顶级域名。想必这些国家的网站在设置 document.domain 时会遇到一些困难了:)。

在对IE浏览器进行测试时,也发现了一些奇怪的事情。实验“aa.bb.cc.dd”域名,发现在 IE8+ 下将不能设置 document.domain 为“cc.dd”。经过反复测试发现 IE8+在多节域名下允许设置 的双节域名中,两节单词中要至少有一个大于2个字母,换言之,下列域名都是不允许的:

  • sa.cn
  • o.jp
  • x.m

但下列是允许的:

  • sax.cn
  • o.com
  • xxx.k

暂不知微软用意为何,但可以联想到,新浪微博的短域名 t.cn在有下一级域名的情形下,将不能设置 document.domaint.cn

即便拥有上面的诸多问题,不过都属于特例,除了 IE8+ 的短域名问题,其它基本都不会在日常的开发中遇到。

参考
  1. http://javascript.info/tutorial/same-origin-security-policy
  2. http://msdn.microsoft.com/en-us/library/ie/ms533740(v=vs.85).aspx
  3. https://developer.mozilla.org/en-US/docs/Web/API/document.domain

Js中一些本地(native/built-in)对象和方法是可以重写的,比如在针对低版本浏览器的编程中,我们使用:

Array.prototype.forEach = function(item index arr){
    ...
};

来对ES5中才定义的方法增加polyfill。这样可能会带来的一些潜在的隐患问题暂且不表,但我们可以像在现代浏览器中一样安全地使用forEach方法了(只要你实现地足够稳健)。

当在陌生的或不可控的环境中运行JavaScript代码时,你可能不相信可能被 ‘polyfill’ 过的方法,比如Array.prototype.everyArray.prototype.mapwindow.Promise。这个时候,你需要识别你要使用的方法是一个 JavaScript 宿主环境的本地方法还是一个已经由别人 ‘polyfill’ 过的方法。

不过似乎没有任何标准或草案规定自定义方法和 native 方法应该在行为上表现出任何不同。但可以想象,JavaScript 引擎一般由C++语言编写,似乎 native 方法无法打印出其源代码。

默认情形下,将一个函数对象转成String类型即可输出其源代码,同样将方法名作为参数传入RegExp.prototype.test也会先转成String,John Resig 写的js简单继承实现中即使用这种方式来识别_super单词的。因此对于自定义方法,会输出源码,那么对于 native 方法,目前主流浏览器很一致性地输出类似function func_name() { [native code] }的字符串,甚至nodejs也同样,具体回车换行各种环境实现有略微差异,Javascript 实现的 DOM 选择器sizzle总结了一个通用的正则表达式:

/^[^{]+\{\s*\[native \w/

。通过对方法源码进行正则匹配来识别是本地方法与否。

这种通过源码进行识别的方式有一个问题,可以查看Ecma 262对于Funtion.prototype.toString的定义:

An implementation-dependent representation of the function is returned. This representation has the syntax of a FunctionDeclaration. Note in particular that the use and placement of white space line terminators and semicolons within the representation String is implementation-dependent.

也就是说并未针对native方法做了特殊规定,甚至没有规定方法默认应该输出为其源码。在将来的实现中,对于native方法的默认字符串表示可能出现变化,特别是Google的V8引擎中越来越多地直接使用JavaScript语言本身来编写。

另一方面,这种方式强依赖于toString方法的默认行为,因此重写toString可以达到欺骗的效果,如:

var s = Array.prototype.every = function(){
    return !!'this is a fake forEach!';
    };

s.toString = function(){
    return 'function every(){[native code]}';
};

//true but it shouldn't
/^[^{]+\{\s*\[native \w/.test(s);

一个自定义方法通过了检测。虽然这是一个极端的例子,但对于未有明确标准定义的,特别是将来有可能发生行为改变的内容,最好的使用方式就是不依赖于这种非标准的,不稳定的特性。

这里有一篇比较老的文章,其内容揭示了确实有老版本的Safari浏览器无法通过上面的正则表达式检测。

0%