使用代理解决跨站点请求和上传文件

在做 Web 系统的时候,我们经常会使用 Ajax 技术来实现异步加载数据的功能,开始的时候我们的系统只有一个,但随着业务的发展,我们的 Web 系统可能有很多个,而且每个系统可能都是一个独立的站点,那么当我们直接使用 Ajax 技术来访问其它站点时,就会出现意外。

假设我们直接使用 jQuery 提供的 Ajax 方法来把京东的首页给抓取下来,那么有如下代码:

$.ajax({
url: 'http://www.jd.com/',
type: 'get',
success: function (data) {
//todo:
}
});

但我们在浏览器上跑了这段代码之后,却发现浏览器报了一个错误:

图 1

我当前的站点是 http://localhost:6404,浏览器提示说我当前的站点不被允许访问京东的站点,原因是京东的响应头里面没有Access-Control-Allow-Origin字段,那么Access-Control-Allow-Origin又是什么?

出于安全的因素,浏览器会限制我们从脚本中发起跨站请求,所以 W3C 工作组退出了一种新的机制,即跨源资源共享(Cross-Origin Resource Sharing (CORS)),这种机制让 Web 应用服务器能支持跨站访问控制,从而使得安全地进行跨站数据传输成为可能。而Access-Control-Allow-Origin就是该机制里面所定义的字段。W3C 上有对该字段的准确定义:

图 2

上面说,该响应头决定一个资源是否可以被共享给请求头里面的Origin字段的值。那Origin是什么?我们再看看之前的 Ajax 请求头信息,里面确实有Origin字段:

图 3

请求头里面的Origin字段的值就是我当前的站点地址,我是从http://localhost:6404这个站点向http://www.jd.com这个站点发起的 Ajax 请求的。所以根据 W3C 的文档描述,如果京东的响应头的Access-Control-Allow-Origin字段里面有http://localhost:6404这个值,那么我就可以成功抓取到京东的首页。

由于我改不了京东的响应头Access-Control-Allow-Origin字段,所以我就自己建了一个新的站点来测试一下。

站点http://localhost:6404上发起的请求脚本:

$.ajax({
url: 'http://localhost:6408/index.ashx',
type: 'get',
success: function (data) {
alert(data);
}
});

站点http://localhost:6408用来响应请求的后端代码:

public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "text/plain";
context.Response.Headers.Add("Access-Control-Allow-Origin", "http://localhost:6404");
context.Response.Write(String.Format("我是站点:{0}", "http://localhost:6408"));
}

然后,我们在浏览器上跑一下,得如下结果:

图 4

从结果上可以看出,跨站点请求已经成功。接下来,我们把响应头里面的Access-Control-Allow-Origin字段去掉:

public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "text/plain";
//context.Response.Headers.Add("Access-Control-Allow-Origin", "http://localhost:6404");
context.Response.Write(String.Format("我是站点:{0}", "http://localhost:6408"));
}

然后看一下结果:

图 5

结果果然跟之前的是一样的,这也证明了Access-Control-Allow-Origin在跨站点请求里面扮演的重要角色。

虽然问题解决了,但是新的问题又来了,如果我有 1000 个站点都要用到跨站点请求,那么我难道要把这 1000 个站点都加到响应头里面?所以,这肯定是不行的,那么我如何在不改动响应头的情况下实现跨站点请求呢?

前面说过,导致这个问题的直接原因是由于浏览器限制了我们的脚本,那么这里就有两个前提,一个是浏览器,一个是脚本,如果我们能避开其中一个,是不是就可以实现跨站点请求呢?于是我就想到了在后台代码里面直接构建 Http 请求代理来干掉浏览器。

由于直接使用HttpWebRequest类要配置的东西太多,而且在异步 API 这块写起来太繁琐,所以我使用了一个轻量但功能强大的HttpClient类来实现这一功能。

首先,我们来设计前端脚本 API,为了更好的兼容性,我不打算重新写套 Ajax 请求的 API,而是继续采用$.ajax(),只不过传递的参数不一样而已。所以,就有了下面这个脚本 API:

$.ajax({
//这个url就是代理的地址,这个地址我们可以自己去web.config中配置
url: '/cors',
type: 'get',
data: {
//id是我们自定义的参数
"no": "001",
//authkey是访问代理所需要提供的授权key,这个参数是必需的
"authkey": "e4f58a805a6e1fd0f6bef58c86f9ceb3",
//target是是告诉代理,要把我们自定义的数据提交到哪个地址,这个参数是必需的
"target": "http://localhost:6408/index.ashx"
},
success: function (data) {
alert(data);
}
});

这样一来,我可以在最小改动下实现跨站点请求。

整个请求过程可以通过下图来表示:

图 6

然后再来说说/cors这个地址是怎么来的,这个地址就是代理处理程序的地址,我们可以在Web.config文件里面的节点里进行配置:

  <system.webServer>
<handlers>
<add name="cors" path="/cors" allowPathInfo="true" verb="GET" type="ChuXin.Web.Cors.RequestDispatcher,ChuXin.Web"/>
</handlers>
</system.webServer>

其中,ChuXin.Web.Cors.RequestDispatcher为代理处理程序类,其核心实现代码如下:

public void Process(String target, String authkey, IReadOnlyDictionary<String,String> args = null)
{
//拼接到目标站点的请求Url
var argList = from t0 in args
select t0.Key + "=" + t0.Value;
String url = String.Format("{0}?{1}", target, String.Join("&", argList));

HttpClient hc = new HttpClient();
HttpContext context = HttpContext.Current;
Object result = null;

//使用HttpClient发起异步请求
hc.GetAsync(url, HttpCompletionOption.ResponseContentRead).ContinueWith(t =>
{
//异步请求结束后判断是否有异常或错误
if (HasExceptions<HttpResponseMessage>(t, context)) { return; }

//如果任务正常完成
if (t.IsCompleted) {
//获取响应消息对象
var respMessage = t.Result;
if (respMessage != null && respMessage.Content != null) {
//获取目标站点响应头的Content-Type属性,并赋值给当前代理的响应头
var contentTypes = respMessage.Content.Headers.GetValues("Content-Type");
context.Response.ContentType = String.Concat(contentTypes);
String contentType = null;
foreach (var ct in contentTypes) {
String[] parts = ct.Split(new Char[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) { contentType = parts[0].Trim(); }
}
if (contentType.StartsWith("text/")) {
//如果content-type为文本类型的,则先以异步方式读取二进制数据,
//然后转成UTF8编码字符串赋值给结果对象
respMessage.Content.ReadAsByteArrayAsync().ContinueWith(t1 =>
{
if (HasExceptions<Byte[]>(t1, context)) { return; }

if (t1.IsCompleted) {
result = Encoding.UTF8.GetString(t1.Result);
}
}).Wait();
}
else {
//如果content-type为非文本类型的,则直接以异步方式读取并赋值给结果对象
respMessage.Content.ReadAsByteArrayAsync().ContinueWith(t1 =>
{
if (HasExceptions<Byte[]>(t1, context)) { return; }

if (t1.IsCompleted) {
result = t1.Result;
}
}).Wait();
}
}
}
}).Wait();
//如果结果为字符串,则直接输出
if (result is String) {
context.Response.Write(result);
}
//如果结果为二进制数据,则写入到输出流里面
if (result is Byte[]) {
var outputStream = context.Response.OutputStream;
if (outputStream.CanWrite) {
var data = result as Byte[];
outputStream.Write(data, 0, data.Length);
}
}
}

其中,HasExceptions()方法为我自定义的一个处理任务失败的情况的方法。

我们在浏览器里面跑一下看看,得到下图结果:

图 7

从上图看出,我们设想成功实现了。我们把请求头和响应头的详细信息调出来看看:

请求头:

图 8

响应头:

图 9
图 10

跨站点Get请求数据试验成功,那么跨站点文件上传呢?

我们发现HttpClient类有Post方式的请求,例如PostAsync(String, HttpContent)方法,第一个参数还好理解,那第二个参数HttpContent是啥玩意儿呢?转到定义发现它是一个抽象类,于是乎我就想知道有哪些子类实现了它,于是打开反编译工具我们发现有下面几个类实现了它:

图 11

通过反编译工具,我们看到FormUrlEncodedContent类的描述为A container for name/value tuples encoded using application/x-www-form-urlencoded MIME type.,所以它是适用于application/x-www-form-urlencoded这种类型的,很明显文件不属于这种,StringContent同样不适合。然后我们再看看MultipartFormDataContent类,它的描述为Provides a container for content encoded using multipart/form-data MIME type.,眼尖的同学一定已经发现了,它就是跟我们上传文件所需的MIME类型一致的类了,所以我们就使用它来封装我们要上传的文件数据。

所以核心代码如下:

public void ProcessRequest(HttpContext context)
{
//获取目标站点地址
String target = context.Request.Unvalidated["target"];
if (String.IsNullOrWhiteSpace(target)) { return; }
target = HttpUtility.UrlDecode(target);

HttpClient client = new HttpClient();

//定义请求的边界值
String boundary = String.Format("-------ChuXinWebBoundary{0}", DateTime.Now.Ticks.ToString("x"));
//将多个文件添加到请求的主内容里面
var content = new MultipartFormDataContent(boundary);
var files = context.Request.Files;
for (Int32 i = 0; i < files.Count; i++) {
var file = files[i];
var buf = new Byte[file.InputStream.Length];
if (file.InputStream.Read(buf, 0, buf.Length) > 0) {
//创建一个二进制内容对象
var dataContent = new ByteArrayContent(buf, 0, buf.Length);
//指定该对象的Content-Type
dataContent.Headers.Add("Content-Type", file.ContentType);
//添加到请求主内容里面
content.Add(dataContent, "file" + i, file.FileName);
}
}

//form表单数据添加的主内容对象里面
var form = context.Request.Form;
foreach (var key in form.AllKeys) {
if (String.Compare(key, "target", true) == 0) { continue; }
var data = Encoding.UTF8.GetBytes(form[key]);
var dataContent = new ByteArrayContent(data);
content.Add(dataContent, key);
}

String result = null;
//执行异步POST提交请求
client.PostAsync(target, content).ContinueWith(t0 =>
{
if (t0.IsCompleted) {
var respMessage = t0.Result;
if (respMessage != null && respMessage.Content != null) {
//请求完成后,异步读取目标站点的响应结果信息
respMessage.Content.ReadAsStringAsync().ContinueWith(t1 =>
{
if (t1.IsCompleted) {
result = t1.Result;
}
}).Wait();
}
}
}).Wait();
//获取到响应结果信息后输出
context.Response.Write(result);
client.Dispose();
}

然后我们测试一下:

首先,请求发起站点(http://localhost:6404/index.html)要得有一个 form 表单:

<form action="/cors" method="post" enctype="multipart/form-data">
<input name="myfile" type="file" /><br /><br />
<input name="authkey" type="hidden" value="e4f58a805a6e1fd0f6bef58c86f9ceb3" />
<input name="target" type="hidden" value="http://localhost:6408/file/upload" />
<input name="dir" type="hidden" value="~/uploaddir/" />
<input type="submit" value="提交" />
</form>

然后,在请求发起站点的web.config文件中配置代理:

<system.webServer>
<handlers>
<add name="cors" path="/cors" verb="GET,POST" type="ChuXin.Web.Cors.RequestDispatcher,ChuXin.Web"/>
</handlers>
</system.webServer>

最后选个文件提交一把,得如下结果:

图 12

这个就是我上传的图片文件地址,它已成功上传到http://localhost:6408这个站点的uploaddir目录。

参考文献

  • https://www.w3.org/TR/cors/
  • https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS