前言
本文是笔者于 2008 年写的《当年那些风骚的跨域操作》的重制威力加强版。
古云温故而知新,重看当年的文章,还是感觉有颇多不足和疏漏,思考深度也还欠缺,故进行重制。
本人个人能力有限,欢迎批评指正。
正名与致歉
开头先来个一鞠躬。
吐槽一下翻译,浏览各类 Wiki 和 RFC 里英文名都是“cross-origin”,origin 应该翻译成“源”、“来源”,合起来准确的翻译是“跨源”。但不知怎么搞的,在中文区以讹传讹地变成了“跨域”(cross-domain),这也造成了很大的误解,下面有讲到 domian 和 origin 的关系,这糟心的翻译让多少萌新混淆了这两个概念(包括我)。
因此,本文只会用”跨源“这个准确翻译,也为自己以前文章的错误致歉。
演示案例
本次重制最重磅的一点是笔者实现了一套完整的演示案例,前后端代码都有,前端无第三方依赖,服务端基于 Express,源码细节一览无余,理论和实践完美结合。可在本地演示下述的所有跨源方案,有 NodeJS 环境就能玩,无需复杂配置、编译和容器。 传送门
首页截图:
同源策略(Same-Origin Policy)
1995年,同源策略由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个安全策略。
本文要讲的“跨源”,正是要在确保安全的前提下绕过这个策略的限制。
核心概念
同源策略的目的是确保不同源提供的文件(资源)之间是相互独立的,类似于沙盒的概念。换句话说,只有当不同的文件脚本是由相同的源提供时才没有限制。限制可以细分为两个方面:
对象访问限制
主要体现在 iframe,如果父子页面属于不同的源,那将有下面的限制:- 不可以相互访问 DOM(Document Object Model),也就是无法取得 document 节点,document 下面挂载的方式和属性,包括其所有子节点都无法访问。这也是 Cookie 遵循同源策略的原因,因为
document.cookie
不可能访问。 对于 BOM 只能有少量权限,也就是说可以互相取得 window 对象,但全部方法和大部分属性都无法用(比如
window.localStorage
、window.name
等),只有少量属性可以有限访问,比如下面两种:- 可读,
window.length
。 - 可写,
window.location.href
。
- 可读,
- 不可以相互访问 DOM(Document Object Model),也就是无法取得 document 节点,document 下面挂载的方式和属性,包括其所有子节点都无法访问。这也是 Cookie 遵循同源策略的原因,因为
网络访问限制
主要体现在 Ajax 请求,如果发起的请求目标源与当前页面的源不同,浏览器就会有下面的限制:
何为同源(Same-Origin)
origin 在 Web 领域是有严格定义的,包含三个部分:协议、域和端口。
origin = scheme + domain + port
也就是说这三者都完全相同,才能叫同源。
举个例子,假设现在有一个源为 http://example.com
的页面,向如下源发起请求,结果如下:
origin(URL) | result | reason |
---|---|---|
http://example.com | success | 协议、域和端口号均相同(浏览器默认 80 端口) |
http://example.com:8080 | fail | 端口不同 |
https://example.com | fail | 协议不同 |
http://sub.example.com | fail | 域名不同 |
跨源方案(Cross-Origin)
同源策略提出的时代还是传统 MVC 架构(jsp,asp)盛行的年代,那时候的页面靠服务器渲染完成了大部分填充,开发者也不会维护独立的 API 服务,所以其实跨源的需求是比较少的。
新时代前后端的分离和第三方 JSSDK 的兴起,我们才开始发现这个策略虽然大大提高了浏览器的安全性,但有时很不方便,合理的用途也受到影响。比如:
- 独立的 API 服务为了方便管理使用了独立的域名;
- 前端开发者本地调试需要使用远程的 API;
- 第三方开发的 JSSDK 需要嵌入到别人的页面中使用;
- 公共平台的开放 API。
于是乎,如何解决这些问题的跨源方案就被纷纷提出,可谓百家争鸣,其中不乏令人惊叹的骚操作,虽然现在已有标准的 CORS 方案,但对于深入理解浏览器与服务器的交互还是值得学习的。
JSON-P(自填充JSON)
JSON-P 是各类跨源方案中流行度较高的一个,现在在某些要兼容旧浏览器的环境下还会被使用,著名的 jQuery 也封装其方法。
原理
请勿见名知义,名字中的 P 是 padding,“填充”的意思,这个方法在通信过程中使用的并不是普通的 json 格式文本,而是“自带填充功能的 JavaScript 脚本”。
如何理解“自带填充功能的 JavaScript 脚本”?看看下面的例子。
假设全局(Window)上有这个 getAnimal 函数,然后通过 script 标签的方式,引入一个调用该函数并传入数据的脚本,就可以实现跨源通信。
// 全局上有这个函数
function getAnimal(data){
// 取得数据
var animal = data.name
// do someting
}
另一个脚本:
// 调用函数
getAnimal({
name: 'cat'
})
也就是说利用浏览器引入 JavaScript 脚本时会自动运行的特点,就可以用来给全局函数传递数据。如果把这段调用函数的脚本作为服务端 API 的输出,就可以以此实现跨源通信。这就是 JSON-P 方法的核心原理,它填充的是全局函数的数据。
流程
- 在全局定义好回调函数,也就是服务端 API 输出的 js 脚本中要调用的函数;
- 新建 script 标签,src 即是 API 地址,将标签插入页面,浏览器便会发起 GET 请求;
- 服务器根据请求生成 js 脚本并返回;
- 页面等待 script 标签就绪,就会自动调用全局定义的回调函数,取得数据。
【PS】不只是 script 标签,所有可以使用 src 属性的标签都可以不受同源策略限制发起 GET 请求(CSP 未配置的情况),比如 img、object 等,但能自动运行 js 代码的只有 script 标签。
错误处理
- 前端通过 script 标签的 error 事件可以捕获到网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- 服务端返回的脚本如果运行错误,前端只能通过全局 error 事件捕获。
实践提示
前端
- 为了避免污染全局,不建议前端生成大量随机名字的全局函数,可以用一个对象保存所有回调函数,并给每一个回调函数唯一的 id,全局仅暴露统一的执行器,依靠 id 去调用回调函数;
- 如果遵从上一条的建议,全局对象内回调函数需要及时清理;
- 每次请求都要生成新的 script 标签,应该在完成后及时清理;
- 为了灵活性,还可与服务端约定将回调函数名作为参数传递,保留多个全局对象情况的扩展空间。
服务端
- 只需接收 GET 方法的请求,其他方法可判定为非法;
- 只能在请求的 URL 里获取参数,比如 query 或 path;
- 响应报文头 content-type 设为 text/javascript;
- 强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 HTTP的文章;
- 返回的脚本以纯文本格式写入响应报文体,由于脚本是直接运行的,应特别注意 XSS 攻击。
前端“一个对象保存所有回调函数”的设计思路:
function initJSONPCallback() {
// 保存回调对象的对象
const cbStore = {}
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 统一执行器(函数)。
run: function (statusCode, data) {
const { callbackId, msg } = data
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
},
// 设置回调对象,发起请求时调用。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象,清理时调用。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化
const JSONPCallback = initJSONPCallback()
// 全局暴露执行器,这也是 API 返回脚本调用的函数。
window.JSONPCb = JSONPCallback.run
具体代码请参考演示案例 JSONP 部分源码。
总结
优点
- 简单快速,相比需要 iframe 的方案确实快(演示案例里体验一下就知道);
- 支持上古级别的浏览器(IE8-);
- 对域无要求,可用于第三方 API。
缺点
- 只能是 GET 方法,无法自定义请求报文头,无法写入请求报文体;
- 请求数据量受 URL 最大长度限制(不同浏览器不一);
- 调试困难,服务器错误无法检测到具体原因;
- 需要特殊接口支持,不能使用标准的 API 规范。
SubHostProxy(子域名代理)
子域名代理在特定的环境条件下是很实用跨源方案,它能提供与正常 Ajax 请求无差别的体验。
原理
先搞清楚何为子域(domain)?域名的解析是从右往左的,我们去申请域名,就是申请最靠右的两段(以点为分段),而之后的部分是可以给所有者自定义的,你想多加几段都可以,这些衍生的域就是子域。举个例子,api.demo.com
就是 demo.com
的子域。
理论上例子里的两个算是不同的域,依照上面提到的 domain 是 origin 的一部分,因此也算是不同的源,但浏览器允许将页面 document 的域改为当前域的父级,也就是在 api.demo.com
的页面运行如下代码就可以改为 demo.com
,但这种修改只对 document 的权限有影响,对 Ajax 是无影响的。
// 在 api.demo.com 页面写如下代码
document.domain = 'demo.com'
【PS】document.domain
的特点:只能设置一次;只能更改域部分,不能修改页面的端口号和协议;会重置当前页面的端口为协议默认端口(即 80 或 433);仅对 document 起作用,不影响其他对象的同源策略。
因此,该方案的原理就是通过这种方法使父级页面拥有子域页面 document 的访问权限,子域恰好又是 API 的域,进而通过子域页面代理发起请求,实现跨源通信。
流程
假设服务端 API 的域为 api.demo.com
,页面域为 demo.com
,共同运行在 http 协议,端口为 80。
- 子域下部署一个代理页,设置其域为
demo.com
,并可以包含发起 Ajax 的工具(jQuery、Axios等); - 主页面也设置域为
demo.com
; - 主页面新建 iframe 标签链接到代理页;
- 当 iframe 内的代理页就绪时,父页面就可以使用
iframe.contentWindow
取得代理页的控制权,使用其发起 Ajax 请求。
错误处理
- iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;
- 通过 iframe 的 load 事件可以检查代理页是否被加载,以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- 当主页面获取代理页的控制权后,错误处理与正常 Ajax 无异。
实践提示
前端
- 加载代理页是需要耗时的(其实挺慢的),因此要注意发起请求的时机,免在代理页还未加载完的时候请求;
- 并不需要每次请求都加载新的代理页,强烈建议只保留一个,多个请求共享;
- 如果遵从上一条的建议,还需考虑代理页加载失败的情况,避免一次失败后后续均不可以;
- 可以使用预加载的方式提前加载代理页,以免增加请求的时间;
- 主页面必须要使用
document.domain
设置,即是当前域已经满足要求,也就是说当前页面虽然已经域是xxx
,但还是得调用一遍document.domain='xxx'
。
服务端
- 只能使用标准的 80(http)或 443(https)端口部署(或使用反向代理);
- 代理页的域必须与 API 的域是一致的,并且与主页的域面有共同的父级(或主页面的域就是父级);
- 理论上代理页只要是执行了
document.domain=xxx
的 HTML 格式文件即可,因此可以尽量精简。
共享 iframe 的设计思路:
// 将创建 iframe 用 promise 封装,并保存起来。
let initSubHostProxyPromise = null
// 每次请求之前都应先调用这个函数。
function initSubHostProxy() {
if (initSubHostProxyPromise != null) {
// 如果 promise 已经存在,则直接返回,由于这个 promise 已经 resolve,其实就相当于返回了已有的 iframe。
return initSubHostProxyPromise
}
// 没有则重新创建。
initSubHostProxyPromise = new Promise((resolve, reject) => {
const iframe = document.createElement('iframe')
// 填入代理页地址。
iframe.src = '...'
iframe.onload = function (event) {
// 这是一种 hack 的检测错误的方法,见演示案例 README 。
if (event.target.contentWindow.length === 0) {
// 失败分支
reject(new Error(...))
setTimeout(() => {
// 清理掉失败的 promise,这样下次就会重新创建。
initSubHostProxyPromise = null
// 这里还需移除 iframe。
document.body.removeChild(iframe)
})
return
}
// 成功分支,返回 iframe DOM 对象。
resolve(iframe)
}
document.body.appendChild(iframe)
})
return initSubHostProxyPromise
}
具体代码请参考演示案例 SubHostProxy 部分源码。
总结
优点
- 可以发送任意类型的请求;
- 可以使用标准的 API 规范;
- 能提供与正常 Ajax 请求无差别的体验;
- 错误捕获方便准确(除了 iframe 的网络错误);
- 支持上古级别的浏览器(IE8-)。
缺点
- 对域有严格要求,不能用于第三方 API;
- iframe 对浏览器性能影响较大;
- 无法使用非协议默认端口。
HTML-P/MockForm(自填充HTML/模拟表单)
网上一般称这种方案是“模拟表单”,但我觉得并不准确,使用表单发起请求并不是它的核心特征(后面也还有几种方案用到),它的核心应该是“自填充HTML”。
原理
我将它称为 HTML-P 是借鉴了 JSON-P 的叫法,它的思路也与 JSON-P 方案很像,服务端 API 返回一个 js 脚本可以自动运行进行数据填充,那直接返回整个 HTML 页面不也可以。
但实际上 HTML 要实现数据填充还是有限制的,首先就是同源限制,父子页面如果不同源,就无法互相访问,解决办法自然是“子域代理”里提到的 document.domain
修改大法,但它的目的恰好与“子域代理”相反,通过修改 document 的域,使子页面获取主页面的访问权限,以此对主页面的数据填充,实现跨源通信。
// API 返回包含如下脚本的 HTML ,就可访问父级页面的全局函数进行数据填充。
document.domain = 'xxx'
window.parent.callbackFunction(...)
至于表单的作用,其实是利用了表单的 target 的属性,当表单 submit 的时候它会使指定 name 的 iframe 进行跳转,跳转其实就是发起请求,因此浏览器表单组件原生支持的请求方法都可以使用,也正因为使用了表单发起请求,服务端 API 必须返回一个 HTML 格式的文本。
流程
假设服务端 API 的域为 api.demo.com
,页面域为 demo.com
,共同运行在 http 协议,端口为 80。
- 在全局定义好回调函数,也就是服务端 API 输出的 HTML 中要调用的函数;
- 主页面设置域为
demo.com
; - 主页面新建 iframe 标签并指定 name ;
- 新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;
- 提交表单,iframe 内跳转;
- 服务端接收到请求,依据请求参数生成 HTML 页面并返回,其域设为
demo.com
; - iframe 完成 HTML 的加载,子页面调用主页面全局定义的回调函数,主页面取得数据。
错误处理
- iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;
- 通过 iframe 的 load 事件可以检查代理页是否被加载,以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- 子页面调用主页面发生的错误属于 iframe 内错误,因此也是不可知的。
实践提示
前端
- 为了避免污染全局,不建议前端生成大量随机名字的全局函数,可以用一个对象保存所有回调函数,这点可以参考上面 JSON-P ;
- 主页面必须要使用
document.domain
设置,即是当前域已经满足要求。 - 由于 iframe 内的页面每次请求都不同,因此可以复用 iframe 标签,但不可复用页面;
- 并发时会同时生成多个 iframe 页面,这将导致性能极度下降,并发场景并不适用该方案;
- form 和 iframe 标签应该在完成后及时清理;
服务端
- 只能使用标准的 80(http)或 443(https)端口部署(或使用反向代理);
- API 的域与主页的域面有共同的父级(或主页面的域就是父级);
- 响应报文头 content-type 设为 text/html;
- 强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 HTTP的文章;
- 返回的 HTML 以纯文本格式写入响应报文体,由于其中的脚本是直接运行的,应特别注意 XSS 攻击;
- 生成的 HTML 应尽量精简。
具体代码请参考演示案例 MockForm 部分源码。
总结
该方案可以说是“JSON-P”与“子域代理”的缝合版,优缺点均有继承。
优点
- 可以发送任意类型的请求(以浏览器 form 标签支持为准);
- 相比“子域代理”来说,无需代理页算是个优点,
- 支持上古级别的浏览器(IE8-)。
缺点
- 对域有严格要求,不能用于第三方 API;
- iframe 对浏览器性能影响较大,并且并发需要多个 iframe ,基本不能用于需要并发的场景;
- 无法使用非协议默认端口。
- 错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;
- 需要特殊接口支持,不能使用标准的 API 规范。
WindowName
这是一个以 window.name 特性为核心的方案。
原理
这方案利用了 window.name 的特性:一旦被赋值后,当窗口(iframe)被重定向到一个新的 url 时不会改变它的值。虽然 window.name 依然遵循同源策略,只有同源才能读取到值,但我们只要在非同源页面写入值,再重定向到同源页面读取值即可实现跨源通信。
发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。
// 通过 iframe 的 load 事件取得 window.name 的值。
iframe.onload = function (event) {
const res = event.target.contentWindow.name
}
流程
- 主页面新建 iframe 标签并指定 name ;
- 新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;
- 提交表单,iframe 内跳转;
- 服务端接收到请求,依据请求参数生成 HTML 页面并返回;
- iframe 加载 HTML ,运行其中脚本将数据设置到 window.name ,并重定向;
- iframe 再次加载 HTML ,完成时触发 load 事件;
- 主页面监听到 iframe 的 load 事件,获取其 window.name 的值。
错误处理
- iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;
- 通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- 其余错误可正常捕捉即可。
实践提示
前端
- form 和 iframe 相关注意点与“HTML-P”相同;
- 重定向到同域的页面理论上无需任何内容,只要有 HTML 格式即可,应尽量精简,而且由于无需改变,可进行长期缓存;
- 虽然理论上 iframe 的 load 事件会触发两次(一次非同源页、一次同源页),但实际上只要 load 触发前重定向,非同源页面的 load 事件是不会接收到的;
- 重定向应使用
window.location.replace
,这样才不会产生 history ,会影响主页面的后退操作; - 为了灵活性,建议将重定向页面的 url 传递给服务端。
服务端
- 响应报文头 content-type 设为 text/html;
- 强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 HTTP的文章;
- 返回的 HTML 以纯文本格式写入响应报文体;
- 生成的 HTML 应尽量精简。
具体代码请参考演示案例 WindowName 部分源码。
总结
优点
- 可以发送任意类型的请求(以浏览器 form 标签支持为准);
- 对域无要求,可用于第三方 API ;
- 支持上古级别的浏览器(IE8-)。
缺点
- iframe 对浏览器性能影响较大,两次跳转雪上加霜,并且并发需要多个 iframe ,基本不能用于需要并发的场景;
- 近乎是空白的同源重定向页,可以说是无意义的流量,影响流量统计;
- 错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;
- 需要特殊接口支持,不能使用标准的 API 规范。
WindowHash
这是一个以 url 上 hash 部分为核心的方案。
原理
这个方案利用了 window.location.hash
的特性:不同域的页面,可以写不可读。而只改变哈希部分(井号后面)不会导致页面跳转。也就是可以让非同源的子页面写主页面 url 的 hash 部分,主页面通过监听 hash 变化,实现跨源通信。
发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。
// 现代浏览器有 hashchange 事件可以监听。
window.addEventListener('hashchange', function () {
// 读取 hash
const hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
} else {
return
}
})
// 降级方案,循环读取 hash 进行“监听”。
var listener = function(){
// 读取 hash
var hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
}
// 继续监听
setTimeout(listener, 100)
}
listener()
流程
- 主页面新建 iframe 标签并指定 name ;
- 新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;
- 提交表单,iframe 内跳转;
- 服务端接收到请求,依据请求参数生成 HTML 页面并返回;
- iframe 加载 HTML ,运行其中脚本修改主页面的 hash;
- 主页面监听 hash 的变化,每次获取 hash 值后清空 hash。
错误处理
- iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;
- 通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- 其余错误可正常捕捉即可。
实践提示
前端
- form 和 iframe 相关注意点与“HTML-P”相同;
- 设置主页面 hash 应该用
window.location.replace
,这样才不会产生 history ,会影响主页面的后退操作; - 每次 hash 设置都需要一定的冷却,并发可能发生错了;
- 没必要每次请求都去监听 hashchange 事件,可以在初始化时设置一个统一事件处理器,用一个对象将每次请求的回调保存起来,分配唯一的 id ,通过统一的事件处理器按 id 调用回调;
- 如果遵从上一条的建议,全局对象内回调函数需要及时清理;
- 由于 iframe 内是非同源页面(服务端生成),不可知主页面 url ,因此需要将 url 通过参数传递给服务端。
服务端
- 响应报文头 content-type 设为 text/html;
- 强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 HTTP的文章;
- 返回的 HTML 以纯文本格式写入响应报文体;
- 生成的 HTML 应尽量精简。
前端“统一事件处理器”的设计思路:
function initHashListener() {
// 保存回调对象的对象
const cbStore = {}
// 设置监听,只需一个。
window.addEventListener('hashchange', function () {
// 处理 hash。
...
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
})
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 设置回调对象的方法。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象的方法。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化,每次请求都调用其 set 方法设置回调对象。
const hashListener = initHashListener()
具体代码请参考演示案例 WindowHash 部分源码。
总结
优点
- 可以发送任意类型的请求(以浏览器 form 标签支持为准);
- 对域无要求,可用于第三方 API ;
- 支持上古级别的浏览器(IE8-)。
缺点
- iframe 对浏览器性能影响较大,并且并发需要多个 iframe ,基本不能用于需要并发的场景;
- 并发场景很容易出现 hash 操作撞车的问题,这个问题如果采用循环读取 hash 的方法监听则更加严重,除非有更加严密的防撞车机制,否则强烈不建议并发使用;
- 请求数据量受 URL 最大长度限制(不同浏览器不一);
- 错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;
- 需要特殊接口支持,不能使用标准的 API 规范。
下篇我们将探讨现代标准(HTML5)的跨源,今日篇
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。