头图

一、问题背景

前后端分离的 web 项目,前端本地开发的 URL 大多是“http+localhost+端口”,如 http://localhost:3000。用此 URL 开发,会遇到三类问题。一是跨域,二是 cookie 种植,三是调用外部服务需要真实域名。

跨域问题。如果你遇到跨域问题,大概率就会看过如下图片,此时浏览器不让你把请求发送到出去。

2024-01-06-10-50-51

为什么会这样?背后的原因是浏览器的同源策略(Same-origin policy)。URL 包括协议、域名和端口。同源策略是指两个源相互之间只有同源,换句话说就是协议、域名和端口都一致,才能相互通信。这样做本意是保证请求的安全,减少恶意攻击,但也给开发带来了麻烦。

cookie 种植问题。如果前后端的鉴权涉及 cookie,那 localhost 之类的域名会影响 cookie 的种植。因为浏览器能否正确种植下 cookie,受到 domain 的影响。

调用外部服务需要真实域名。当我们项目调用外部服务时,经常需要完整的真实域名,比如微信登录。此时本地开发没有完整的真实域名,就无法在本地联调。

那么,该如何解决这些问题呢?

二、解决方案

1. 后端配置 CORS

CORS(跨源资源共享)是一种基于 HTTP 请求头的机制,该机制通过允许服务器标示除了它自己以外的其他源。因此,只要后端在 HTTP 请求的返回头设置好相关配置,允许该域名通过,那么浏览器就不会阻拦前端发送出请求。

比如下面是 python 服务端的配置,该服务除了自身的请求,也允许来自“http://localhost:3000”和“http://127.0.0.1:3000”的请求。

if ENV == 'dev':
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

使用此方法,有一些细节点要记住。1)对于后端服务,通常只需在测试环境才加入此 middleware,因为线上是用 nginx 代理,不需要此配置。2)对于前端,需要在请求头添加{credentials: "include"},这样才能传送 cookie。

fetch(url, {credentials: "include"});

这种方法可以解决跨域问题,但从开发角度,有几个缺点。一是此方案涉及前后端,增加了交流沟通成本;二是我认为更严重的缺点,那就是本地和线上的解决问题的思路不一致。线上用了 nginx 代理,而本地没代理,结果就是线上和本地的代码不一致,而这些不一致的地方就是容易出现 bug 的地方。并且每一次往线上发布代码,你不得不确认下不一致的地方有没有问题,无形中增加了开发的负担。

2. API 代理

另一种常见解决跨域的方法是 API 代理。既然跨域是由于浏览器的限制才会出现,那么,只要不用浏览器发送请求就不会有跨域问题。因此,让前端先将请求发送到(不会跨域)的代理服务器,再由代理服务器请求真正后端就成了有效的解决方案。

proxy

使用 React、Vue 等前端框架搭建的前端,在本地开发时,通常是有一个本地服务器,而此服务器就可以配置 API 代理。比如 nextjs,只需在 nextjs.config.js 中配置 rewrites:

const nextConfig = {
  async rewrites() {
    const rules =
      process.env.NODE_ENV === "development"
        ? [
            {
              source: "/api/:path*",
              destination: `http://127.0.0.1:8000/:path*`,
            },
          ]
        : [];
    return rules;
  },
};

使用代理时,从前端发送到后端的请求,就不需要在 url 前面添加后端域名 process.env.BASEURL,而是前端请求统一加上/api 即可(所有带有/api 的请求都会通过此处被转发到后端)。

const url = `/api${api}`;

这样子配置之后,不仅不需要后端配置 CORS,也不需要前端在请求接口时,区分开发和线上不同的后端域名,更加简洁。

最终流程:

2024-01-06-11-31-49

whistle

不过如果单纯使用 proxy,本地开发的 URL 还是 http://localhost:3000 的话,那还会出现 cookie 种植和调用外部服务需要真实域名这两个问题。该怎么办呢?

你还可以使用 whistle。whistle 是一个基于 Node 实现的跨平台 web 调试代理工具,类似的工具有 Windows 平台上的 Fiddler,主要用于查看、修改 HTTP、HTTPS、Websocket 的请求、响应。

在本地开启 whistle 服务之后,只需要填入域名和代理的域名以及端口,就实现真实域名关联到本地带有端口的前后端服务。

2024-01-06-10-49-13

当然,光是在 whistle 这样配置还不够。当你在浏览器输入域名之后,浏览器并不知道要先将请求发送到 whistle 代理服务器,而是直接发送到互联网上。因此,配合 whistle,需要一个浏览器代理插件,通常是 Proxy SwitchyOmega。

使用 Proxy SwitchyOmega 很简单,在 whistle 的文档中就有详细说明。在下载插件之后,只需配置 Proxy SwitchyOmega 要代理 8899 端口(因为 whistle 在本地的代理端口是 8899。),当你开启该情景模式 proxy 之后,通过浏览器发送的请求,就会先到达 whistle,从而正确代理。

2024-01-06-10-50-13

最终流程:

2024-01-06-11-32-38

nginx

whistle 很灵活,大部分前端本地开发时的 URL 配置问题都能够解决。但它还有一个缺点,那就是和线上环境不完全一致。作为 web 开发多年,我的一个感受是本地环境与线上环境越类似越好。既然线上是使用 nginx 代理,那本地也用 nginx,就能省却许多环境问题。但是我们大家也都知道,本地开启 nginx 并不方便。一来 nginx 的服务列表没有一个可视化工具,我们经常忘记该服务是开启还是关闭;二来 nginx 配置文件也很分散,我们不容易知道配置的内容。所以,许多人本地开发是不会开启 nginx。

不过有了 dokcer 之后,上述两个问题都不见了,因为你可以通过 docker 部署 nginx。你只需将 nginx.conf 和 docker-compose 写在一个统一的地方。那么是否开启本地 nginx,以及开启之后 nginx 有哪些能力,就变成一行命令的问题,非常简单。
配置好 nginx.conf,以及相关的 nginx 证书;然后写好 docker-compose.yml 文件;在当前文件夹运行 docker-compose 就能一键开启本地 nginx。

以下是一个例子。所有来自www.example.com的请求,都会被代理到本地3000端口(假设本地前端3000端口);所有带有/api的请求都会被代理到8000端口(假设本地的后端服务在8000端口)。这样配置之后,就可以在本地用nginx代理URL。

# nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    # access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;
    client_max_body_size 15M;

    server {
        listen 80;
        server_name www.example.com;

        return 301 https://www.example.com$request_uri;
    }

    server {
        listen 80;
        server_name example.com;

        return 301 https://www.example.com$request_uri;
    }

    server {
        listen 443 ssl;
        server_name example.com;

        ssl_certificate /etc/nginx/ssl/server.crt;
        ssl_certificate_key /etc/nginx/ssl/server.key;

        ssl_session_cache    shared:SSL:1m;
        ssl_session_timeout  5m;

        ssl_ciphers  HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers  on;

        return 301 https://www.example.com$request_uri;
    }

    server {
        listen 443 ssl;
        server_name www.example.com;
        # 本地https证书
        ssl_certificate /etc/nginx/ssl/server.crt;
        ssl_certificate_key /etc/nginx/ssl/server.key;

        ssl_session_cache    shared:SSL:1m;
        ssl_session_timeout  5m;

        ssl_ciphers  HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers  on;


        # 前端代理
        location / {
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            proxy_pass http://host.docker.internal:3000;
        }

        # 后端代理
        location /api/ {
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Origin $http_origin;
            proxy_set_header X-Forwarded-Host $server_name;
            proxy_read_timeout 300s;

            add_header 'Access-Control-Allow-Origin' $http_origin always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'authorization,Content-Type,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Range';

            # 其他的配置...
            if ($request_method = 'OPTIONS') {
               return 204;
            }

            rewrite ^/api/(.*) /$1  break;
            proxy_pass http://host.docker.internal:8000;
        }
    }
}
# docker-compose.yml
# 后端部署
version: "3.8"

services:
  nginx:
    image: nginx:1.25-perl
    restart: always
    volumes:
      - ./nginx/conf:/etc/nginx
    ports:
      - "80:80"
      - "443:443"
      - "8443:8443"
    networks:
      - shared-network

networks:
  shared-network:
    external: true

当然,如果你在本地使用 nginx,还得修改 hosts,不然在浏览器的请求不会到达本地 nginx。有一些工具也能帮你快速切换本地 hosts 的状态,我用 Mac 电脑,使用的工具是 iHost。

最终流程:

2024-01-06-11-33-16

总结

本文讨论了前端开发时,如何设置本地的 URL。如果目标是“保持本地环境和线上环境越相似越好”,那么可以使用 docker 开启本地 nginx 服务的方式;如果需要更灵活的代理,可以使用 whistle+Proxy SwitchyOmega。

参考

Using HTTP cookies - HTTP | MDN

Cross-Origin Resource Sharing (CORS) - HTTP | MDN

Same-origin policy - Security on the web | MDN

Proxy SwitchyOmega

关于 whistle · GitBook

iHosts - /etc/hosts editor on the Mac App Store


卡牌大师
1 声望0 粉丝

终不似少年游