我的请求哪去了?浏览器的URL机制(实践总结)和请求代理转发(开发环境+生产环境)机制

2,563 阅读16分钟

本文解决的问题

  • 浏览器对于发出去的请求是怎么处理的(跨域等基本概念)?

  • 对于不设置源的请求路径浏览器是怎么处理的?

  • vue的请求代理是怎么配置并生效的?

  • 如果有多个后台服务器时怎么做代理转发?

  • 生产环境中如何做代理转发(以nginx为例)?

为了方便操作,部分请求使用jQuery的$.ajax()方法发送。

基于同源策略产生的跨域分为DOM跨域(例如不允许操作iframe中引用的内容的DOM)和请求跨域,无特殊说明则本文所指的【跨域】均为请求跨域,后文不再重复。

本文中各种代码的运行环境:
系统 -> Windows10
node -> v14.17.6
浏览器 -> chrome 98.0.4758.82(正式版本)
vue/cli -> @vue/cli 4.5.15

浏览器对于发出去的请求是怎么处理的(跨域等基本概念)

首先,跨域,是前端开发中很常见的一个问题,是浏览器基于其安全机制中的同源策略对js作出的一种行为限制,具体来说,也就是一个域的脚本向另一个域的脚本进行交互时,这种交互会被浏览器拦截。

所谓同源,是指请求的客户端和服务端双方的协议、域名、端口均相同。这个概念各位大神已经讲解的很详细了,我这里不再赘述。

特别需要注意的,这种拦截其实拦截的是服务器的返回值,浏览器发出请求的行为是会被允许的,而且服务器可以接收到这个请求,验证一下:

首先,需要通过一个页面发送请求(对于html等无关紧要的部分不做展示,仅展示核心的JS部分)(这个页面的路径是http://127.0.0.1:5500/web/request.html ,与服务器的地址 http://127.0.0.1:11111 端口不同,所以不同源):

$.ajax({
  url: "http://127.0.0.1:11111/url1/get",
  type: "get",
});

$.ajax({
  url: "http://127.0.0.1:11111/url1/post",
  type: "post",
  body: JSON.stringify({ params: "参数一" }),
});

然后,通过node搭建一个简单的服务器接收请求:

"use strict";

var http = require("http");

http
  .createServer(function (request, response) {
    // 在命令行对http请求的URL和method进行展示
    console.log(`收到URL为【${request.url}】的${request.method}请求`);

    // 相应请求
    response.end(
      JSON.stringify({ code: 200, msg: `收到URL为【${request.url}】的请求` })
    );
  })
  .listen(11111);

console.log("服务已经运行在: 127.0.0.1:11111");

运行结果如下图:

浏览器运行结果:
image.png

服务器运行结果:
image.png

可以看到虽然浏览器提示了跨域的错误,但是服务器依然收到了请求。

这也是在处理安全问题时要注意的点,浏览器提示跨域并不代表服务器没有接收到这次请求。

所以所谓的跨域拦截,其实拦截的是服务器的返回值,而不是不允许请求发出。这是因为跨域问题可以通过服务端设置响应头来解决,而想要通过服务端设置响应头来解决跨域,必须先让服务端收到请求并对浏览器进行返回才行。

结论:当一个请求发出时,浏览器不会对请求做任何处理。当请求得到响应,浏览器会进行验证,如果存在跨域问题且服务端的响应头没有配置相应的字段的话,浏览器就会进行拦截。

以上结论来自于个人总结,无任何大佬背书,无任何文档佐证,也不来自哪位大佬,不能保证完全正确。有任何问题欢迎在评论区指出。

对于不设置源的请求路径浏览器是怎么处理的

此处以图片的URL作为示例,其他URL(请求的URL等)同理。
这个描述有一点模糊,但是大家看两个图片的地址描述就能明白:
1:URL只有路径 image.png 2、URL完整 image.png

这两个图片的URL,
一个是//lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/e08da34488b114bd4c665ba2fa520a31.svg
一个是https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/24d837492bab4fafb1775da5b8d390b2~tplv-k3u1fbpfcp-zoom-crop-mark:1304:1304:1304:734.awebp
区别很明显,第一个是直接以路径表示的,第二个则是从协议(https:)开始进行了详细的描述。这两种表示的区别在于:

  • 当一个URL明确表述了自己的源,浏览器会直接进行请求/访问。
  • 当一个URL没有明确表示自己的源的时候,浏览器会进行一定的拼接处理。
    • 如果是以/开头的路径(比如/abc/test),浏览器会在路径的前面拼上当前页面的源(协议 + 域名 + 端口)。
    • 如果是不以/开头的路径(比如abc/test),浏览器会在路径的前面拼上当前页面的源和路径(协议 + 域名 + 端口 + 路径)。
      举个栗子🌰,还是刚才的那个页面,但是发请求的时候是这么发的:
$.ajax({
 url: "/abc/test123",    // 这个路径是以"/"开头的
});

$.ajax({
 url: "abc/test456",     // 这个路径不是"/"开头的
});

那么他们在浏览器中的表现分别是:
/的无源URL image.png

/的无源URL image.png

所以得以确认结论:

  • 当一个URL明确表述了自己的源,浏览器会直接进行请求/访问。
  • 当一个URL没有明确表示自己的源的时候,浏览器会进行一定的拼接处理。
    • 如果是以/开头的路径(比如/abc/test),浏览器会在路径的前面拼上当前页面的源(协议 + 域名 + 端口)。
    • 如果是不以/开头的路径(比如abc/test),浏览器会在路径的前面拼上当前页面的源和路径(协议 + 域名 + 端口 + 路径)。

和上一部分一样,以上结论来自于个人总结,无任何大佬背书,无任何文档佐证,也不来自哪位大佬,不能保证完全正确。有任何问题欢迎在评论区指出。

前置知识结束,热身完成

vue的请求代理是怎么配置并生效的

其实应该分成vue-cli版本和vite版本,但是两者之间的差异很小,所以在这里先把不一致的部分列出来,下面以vite版本为例,vue-cli版本相应进行替换即可(此处仅列出会用到的差异,其余差异请自行阅读相关文档)。

配置开发服务器的字段名:vue.config.js中叫做devServer,vite.config.js中叫做server

路径复写(也可以是【路径覆写】或者【路径覆盖】):vue.config.js中叫做pathRewrite,配置方式为pathRewrite: { ["^/base-api"]: "" },vite.config.js中叫做rewrite,配置方式为rewrite: (p) => p.replace(/^\/base-api/, "")

那么,贴一份完整的代理服务器配置:

// todo ...
    server: {
     port: 18520,  // 服务器运行的端口
     host: true,   // 指定服务器应该监听哪个 IP 地址,true表示将监听所有地址,包括局域网和公网地址
     open: true,   // 在开发服务器启动时自动在浏览器中打开应用程序
     // 反向代理相关配置,此处表示代理的标识路径是/dev-api
     proxy: {
       "/dev-api": {
         target: "192.168.1.208:10010",  // 代理目标,也就是真正的后台服务器的地址
         changeOrigin: true,  // 代理时将请求的origin改为代理的目标的URL
         rewrite: (p) => p.replace(/^\/dev-api/, ""),  // 路径覆写,将作为标识路径的/dev-api重写为空字符串
       },
     },
   },
// todo ...

首先,我们要明确一个流程,之后再去进行验证。

按照上一部分按照有无明确的源对URL的分类,这里对请求也进行一个分类,也就是:

  • 1、服务器的URL中有明确源的请求(比如:http://127.0.0.1:8080/url1/test1
  • 2、服务器的URL中没有明确源的请求(比如:/url2/test2

如果是第一种,就没有太多好说的了,正常的发送请求即可;如果后台服务器与web服务器非同源则可有通过在响应头上添加字段来解决跨域问题,示例服务器:

"use strict";

var http = require("http");

http
.createServer(function (request, response) {
  // 回调函数接收request和response对象
  // 获得http请求的method和URL
  console.log(`收到URL为【${request.url}】的${request.method}请求`);

  // 将HTTP的响应200写入response,同时设置允许跨域的响应头
  response.writeHead(200, {
    "Content-Type": "application/json; charset=utf-8",
    "Access-Control-Allow-Origin": "*", // 允许来自任何域名的跨域请求,生产环境时不要放的这么开,很危险
  });

  // 将HTTP响应的HTML写入response
  response.end(
    JSON.stringify({ code: 200, msg: `收到URL为【${request.url}】的请求` })
  );
})
.listen(11111);

console.log("服务已经运行在: 127.0.0.1:11111");

页面发出请求的js:

$.ajax({
url: "http://192.168.1.208:11111/abc/test123",
});

$.ajax({
url: "http://192.168.1.208:11111/abc/test456",
type: "post",
});

服务器输出:
image.png

浏览器输出:
image.png

如果是第二种,那么就需要一个服务器来代理转发请求了。生产环境中可以使用nginx、阿帕奇等服务器,开发环境则需要使用开发代理服务器,一般项目的脚手架都会自带相关程序,进行一定的配置的即可生效。此处通过vue + vite来做示例,顺便说一下URL复写的意义和用法(react替换相应的配置文件和字段名即可,详细请自行查阅相关文档)。

我们使用如下vite的server配置:

    // vite 相关配置
  server: {
    port: 18520, // 指定服务器运行占用的端口
    host: true, // 指定服务器应该监听哪个 IP 地址,true表示将监听所有地址,包括局域网和公网地址
    open: true, // 在开发服务器启动时自动在浏览器中打开应用程序
    // 配置自定义代理规则
    proxy: {
    // 代理的标识字段,即当url中有此路径就会被代理转发
      "/vite-proxy-rewrite": {  // 这里称为【代理1】
          // 代理转发到的目标
        target: "http://192.168.1.208:11111",
        // 是否修改请求头中的源的地址,或者使用网上流行的说法【虚拟一个服务端接收你的请求并代你发送该请求】
        changeOrigin: true,
        // 路径复写
        rewrite: (p) => p.replace(/^\/vite-proxy-rewrite/, ""),
      },

      "/vite-proxy-no-rewrite": {  // 这里称为【代理2】
        target: "http://192.168.1.208:11111",
      },
    },
  }

以上配置中的代理说明:

  • /vite-proxy-rewrite:这个是含有url复写的配置,按照配置项,会将url中的/vite-proxy-rewrite复写为'',也就是时候如果路径中有/vite-proxy-rewrite就会被清空。举个例子的话,有一个请求的URL为http://127.0.0.1:18520/vite-proxy-rewrite/test1/abc(为什么是127.0.0.1:18250稍后解释),在代理转发之后(改变了源和路径),URL会变成http://192.168.1.208:11111/test1/abc,这也正是我们所需要的效果

  • /vite-proxy-no-rewrite:仅代理转发,不进行路径复写。

nodejs的服务端程序与上一步中的基本相同,唯一的不同点在于去掉了允许跨域的响应头并把请求的HOST输出出来了:

"use strict";

var http = require("http");

http
  .createServer(function (request, response) {
    // 回调函数接收request和response对象
    // 获得http请求的method和URL
    console.log(`收到URL为【${request.url}】的${request.method}请求`);
    console.log(`其Host是:${request.headers.host} \n`);

    // 将HTTP的响应200写入response
    response.writeHead(200, {
      "Content-Type": "application/json; charset=utf-8",
    });

    // 将HTTP响应的HTML写入response,不再添加允许跨域的响应头
    response.end(
      JSON.stringify({ code: 200, msg: `收到URL为【${request.url}】的请求` })
    );
  })
  .listen(11111);

console.log("服务已经运行在: 127.0.0.1:11111 \n");

发出请求(通过axios):

  axios("/vite-proxy-rewrite/vite/shanhai/silu");  
  axios("/vite-proxy-no-rewrite/vite/shanhai/silu");

请求过程分析:

  • 启动这个项目,按照server中的配置,这个页面的地址为:http://127.0.0.1:18520,然后在执行到发送请求的代码后,开始发送请求
  • 首先,路径解析,因为这两个请求都是无源且以/开头的请求,所以浏览器会将页面的源拼在请求源的前面,也就是会将http://127.0.0.1:18520放在请求的前面,此时,两个请求的路径变成:
http://127.0.0.1:18520/vite-proxy-rewrite/vite/shanhai/silu    # 将这个请求称为【请求1】
http://127.0.0.1:18520/vite-proxy-no-rewrite/vite/shanhai/silu    # 将这个请求称为【请求2】
  • 之后浏览器将请求以上述变更后的路径发出
  • vite启动的开发代理服务器接收到了这两个请求,并发现请求1请求2都符合代理转发配置的url,然后根据配置处理请求
  • 请求1符合代理1的代理路径配置规则,走代理1的代理规则
    • 请求地址(请求的url的源)变更为http://192.168.1.208:11111

    • 请求头中的源更换

    • 路径复写

    • 处理后的请求1变为:http://192.168.1.208:11111/vite/shanhai/silu

  • 请求2符合代理2的代理路径配置规则,走代理2的代理规则
    • 请求地址(请求的url的源)变更为http://192.168.1.208:11111(如果有不同的后台服务器的话,在这里配置不同后台地址即可)
    • 请求头中的源更换
    • 处理后的请求2变为:http://192.168.1.208:11111/vite-proxy-no-rewrite/vite/shanhai/silu
  • 将变更后的请求转发出去

本质上来说,浏览器发出的请求到这里就会等待返回值了,之后其实是这个开发服务器根据配置向后台发出请求,是一个新的请求。这个新的请求获得后台的返回值之后将请求的返回值交给浏览器的请求,此时浏览器收到响应,结束请求,请求完成。所谓的代理转发其实只是一种方便理解的说法,实际操作是产生了一个新的请求。

  • 后台收到请求,返回结果给代理服务器
  • 代理服务器将请求结果返回给浏览器页面
  • 请求结束

运行一下试试结果:

服务器输出:

image.png

浏览器输出:

image.png

从浏览器的输出可以看出来,在页面将请求发出去的时候,两个请求的URL结构和Host是很一致的;

而从服务器的输出可以看出来,服务器收到的两个请求,URL结构不一致,Host也不同。

而这种差异,则与我们之前分析的开发服务器的代理转发配置相一致:要求路径复写,将标识URL去掉的,服务器收到的URL就是复写之后的URL;要改变请求的Host的,服务器收到的请求头中的Host也是变更之后的Host。

至此,本文的核心部分就结束了,要讲述的核心内容也就结束了。此时,你应当已经能够解决文首提出的几个问题。

扩展 · 实战

案例一:如何处理有两个后台的项目?

背景:我们公司有两个后端,一个负责用户后台,一个负责活动后台,我现在有一个界面,要展示用户参加的活动,需要同时向这两个后台请求怎么办(通过代理转发的方式规避跨域问题,后台不做处理)?

方案一:把这两个后端打一顿,让他们合并代码并添加跨域配置,然后向任意一人发出请求即可。(推荐,简单粗暴,生效快)

# 具体过程略

方案二:通过开发服务器配置,将请求转发到不同的后台。(不推荐,操作麻烦且可能导致后端产生依赖性,之后将大量工作推到前端)

使用配置如下:

    server: {
      port: 18520,
      host: true, // 指定服务器应该监听哪个 IP 地址,true表示将监听所有地址,包括局域网和公网地址
      open: true, // 在开发服务器启动时自动在浏览器中打开应用程序
      // 配置自定义代理规则
      proxy: {
        "/back-end-a": {
          target: "http://192.168.1.205:11111",    // 将带有'/back-end-a'请求发到后端A同学那里
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/back-end-a/, ""),
        },

        "/back-end-b": {
          target: "http://192.168.1.206:11111",    // 将带有'/back-end-b'请求发到后端B同学那里
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/back-end-b/, ""),
        },
      },
    },

案例二:如何在本地(127.0.0.1)直接将请求发送线上服务器(192.168.1.208)?

背景:线上服务器出现数据问题了,现在需要在本地链接的生产环境的服务器上,应当如何操作?

方案一:这个应该是在实际开发过程中比较常见(起码是在小公司比较常见)的一个需求了,方案很简单,就是直接将代理转发的目标服务器修改为线上服务器即可。但是一个比较麻烦的点在于,这个请求既要通过开发服务器进行一次代理转发,又需要通过生产服务器进行一次代理转发。

这里需要先普及几个前置的小知识:

  • 在生产环境里,前端项目打包之后的代码是交给nginx进行代理的,前端的页面是通过nginx返回给浏览器的,此时页面的源就是nginx的源,或者更准确的说,是【页面将nginx服务器的源作为了自己的源】。也就是说,生产环境依然在进行着代理转发,此时此职能的执行方从开发服务器转为了nginx。也就是说,生产环境的后台服务器接收到的请求,实际上是生产环境的nginx代理转发过去的请求。
  • 开发过程中,为了标识不同的环境,通常生产环境和开发环境会在发出请求时,在请求的路径前加入一个标识路径,这个标识路径会作为代理转发服务器进行路径复写的依据。此处假设开发环境发出的请求标识路径为/dev,生产环境发出请求标识路径为/prod

那么,根据以上前置小知识们可以得到这么一个结论:如果要将请求从开发环境直接发送到生产环境,需要进行两次代理转发,也就是请求路径中要同时具备开发环境和生产环境所需要的标识变量

那么本地的开发服务器使用的配置如下:

    server: {
      port: 18520,
      host: true, 
      open: true, 
      // 配置自定义代理规则
      proxy: {
        "/dev": {
          target: "http://121.89.215.108",    // 假设生产环境前端页面的地址是这里,也就nginx的
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/dev/, ""),
        },
      },
    },

那么,假设有这么一个获取用户信息的请求,其路径为/user/getinfo,那么分析其发出时的状态和其经历。

  • 首先,请求发出。此时发出的请求为了既能够通过开发服务器又能够通过生产服务器,那么需要在请求的前面进行路径拼接,将开发服务器和生产服务器的标识路径都拼接上(严格来说,把标识路径拼接到URL尾巴上也是能够起作用的,但是一般不会这么做,一方面是因为太奇怪了,另一方面也会造成一定的歧义),那么此时的请求路径可以变成/dev/prod/user/getinfo。(为了不被页面的路径影响到,所以请求一定要以/开头不能省略)(/dev/prod无所谓谁先谁后,也无所谓顺序,只要有就行)。
  • 第二步,浏览器处理请求,也就是在请求的前面拼上页面的源(原因已在文章的前部分解释过)。
  • 第三步,浏览器将请求发出,然后请求被开发服务器接受。
  • 第四步,开发服务器收到请求后,发现其与自己的代理规则相匹配,遂对其进行处理并进行代理转发:
    • 先根据复写规则,将路径中的/dev替换为``,也就是删掉了这一部分路径,此时的路径变为了/prod/user/getinfo
    • 然后将处理过的请求发送到指定的服务器(也就是生产服务器http://121.89.215.108);
  • 第五步,生产环境的代理转发服务器nginx收到开发服务器发来的路径为/prod/user/getinfo的请求,然后发现这个请求与自己的代理规则相匹配,然后也对其进行代理转发(生产环境nginx的配置一般不会由前端人员来操作,但是作用都是一致的):
    • 先根据复写规则,将路径中的/prod替换为 ,也就是删掉了这一部分路径,此时的路径变为了/user/getinfo
    • 然后将处理过的请求发送到指定的服务器(也就是实际的后台服务器);
  • 第六步,实际的后台服务器处理请求,去数据库获取数据等,将实际符合请求的数据作为相应数据,响应请求。
  • 第七步,生产环境nginx服务器收到响应数据,然后响应给开发环境代理转发服务器。
  • 第八步,开发环境代理转发服务器收到响应数据,然后响应给浏览器。
  • 第九步,浏览器收到响应的请求,检查响应头等,判断是否存在跨域问题;检查通过,请求响应到页面。
  • 第十步,页面收到响应的数据,请求结束,执行回调函数。

以上,就是如果想要在生产环境通过多次代理转发访问到生产环境服务器的话,要走的大概的流程。整个流程中经过的中转代理转发服务器可以有无限多个,相应的流程也会逐增加。

结语

首先,再次申明,本篇文章全部内容都是作者个人在开发中的经验总结和实际操作结果总结,是作者一个字一个字思索打出来的,不引用任何学术性的内容,无任何大佬背书,无任何文档佐证,也不来自哪位大佬,不能保证完全正确。有任何问题欢迎在评论区指出。如内容有雷同???原创文章雷同纯属巧合。

浏览器的同源机制产生的跨域问题一直是前端工程师在入门时比较困惑的内容之一,比如为什么服务器之间通讯不会产生跨域?为什么我去请求另一个服务器就没有跨域问题,访问你这个服务器就跨域了?前一个是因为跨域本身就是浏览器限制,后一个是因为服务器响应头配置不同。

但是实际了解一下跨域的话,会发现其实跨域并不是什么大问题,或者说这个问题是浏览器在做出限制时就提供了解决方案的,且解决方案不唯一。

前端的开发服务器选择有很多,大部分都有类似热加载、代理转发的功能,比如webpackvite等;也有部分是不带有请求代理转发功能的,比如parcel(好像社区有解决方案),总之善用工具,善用文档,进行一定的配置,大部分人大部分情况下能碰到的问题,绝对不会是孤例,百度一下,总会有答案;愿意的话,深究一下原理,深入探索一下,总会收获一些不一样的 ^_^

前端之路漫漫,诸君共勉!