一、問題背景
前後端分離的 web 項目,前端本地開發的 URL 大多是“http+localhost+端口”,如 http://localhost:3000。用此 URL 開發,會遇到三類問題。一是跨域,二是 cookie 種植,三是調用外部服務需要真實域名。
跨域問題。如果你遇到跨域問題,大概率就會看過如下圖片,此時瀏覽器不讓你把請求發送到出去。
為什麼會這樣?背後的原因是瀏覽器的同源策略(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,也不需要前端在請求接口時,區分開發和線上不同的後端域名,更加簡潔。
最終流程:
whistle
不過如果單純使用 proxy,本地開發的 URL 還是 http://localhost:3000 的話,那還會出現 cookie 種植和調用外部服務需要真實域名這兩個問題。該怎麼辦呢?
你還可以使用 whistle。whistle 是一個基於 Node 實現的跨平台 web 調試代理工具,類似的工具有 Windows 平台上的 Fiddler,主要用於查看、修改 HTTP、HTTPS、Websocket 的請求、響應。
在本地開啓 whistle 服務之後,只需要填入域名和代理的域名以及端口,就實現真實域名關聯到本地帶有端口的前後端服務。
當然,光是在 whistle 這樣配置還不夠。當你在瀏覽器輸入域名之後,瀏覽器並不知道要先將請求發送到 whistle 代理服務器,而是直接發送到互聯網上。因此,配合 whistle,需要一個瀏覽器代理插件,通常是 Proxy SwitchyOmega。
使用 Proxy SwitchyOmega 很簡單,在 whistle 的文檔中就有詳細説明。在下載插件之後,只需配置 Proxy SwitchyOmega 要代理 8899 端口(因為 whistle 在本地的代理端口是 8899。),當你開啓該情景模式 proxy 之後,通過瀏覽器發送的請求,就會先到達 whistle,從而正確代理。
最終流程:
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。
最終流程:
總結
本文討論了前端開發時,如何設置本地的 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