POST为什么发送两次请求

想获取更多高质量的Java技术文章?欢迎访问Java技术小馆官网,持续更新优质内容,助力技术成长 技术小馆官网

你是否曾经在开发过程中发现一个奇怪的现象:明明只触发了一次表单提交,却在网络面板中看到两个POST请求?或者在调试API接口时,一个简单的数据提交却导致服务器收到重复数据?这不是你的代码出了问题,而是浏览器的一种特殊行为机制。

当我第一次遇到这个问题时,也是一头雾水,直到深入了解了HTTP协议和浏览器的工作原理,才恍然大悟。今天,就让我们一起揭开这个困扰许多开发者的谜团,看看那些"幽灵般"的双重POST请求到底是怎么回事。

一、OPTIONS预检请求:双重POST的主要元凶

1. 什么是CORS跨域资源共享

跨域资源共享(CORS)是浏览器的一种安全机制,用于控制不同源之间的HTTP请求。当你的前端应用(例如 https://myapp.com)尝试向不同源的服务器(例如 https://api.otherservice.com)发送请求时,浏览器会执行CORS检查。

简单来说,"同源"要求协议、域名和端口都相同,否则就是跨域请求:

ruby 复制代码
// 同源示例
https://myapp.com/page1.html → https://myapp.com/api/data

// 跨域示例
https://myapp.com → https://api.service.com (不同域名)
http://myapp.com → https://myapp.com (不同协议)
https://myapp.com → https://myapp.com:8080 (不同端口)

2. 预检请求(Preflight)的作用

预检请求是浏览器在发送实际跨域请求前,先发送一个OPTIONS方法的HTTP请求,用来"询问"服务器是否允许接下来的实际请求。这就是为什么你会看到两个请求的原因 - 一个OPTIONS请求和一个实际的POST请求。预检请求的主要作用是保护服务器免受可能有害的跨域请求,特别是那些可能改变服务器数据的请求(如POST、PUT、DELETE等)。

3. 为什么浏览器需要发送OPTIONS请求

浏览器发送OPTIONS请求是为了:

  • 确认服务器是否允许该跨域请求
  • 检查服务器是否接受请求中使用的HTTP方法
  • 验证服务器是否接受请求中携带的自定义头部
  • 确认服务器是否允许请求中的内容类型

一个典型的OPTIONS请求头部如下:

makefile 复制代码
OPTIONS /api/data HTTP/1.1
Host: api.otherservice.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

二、常见的触发双重POST场景

1. 跨域API调用时的情形

最常见的双重POST请求出现在前端应用调用不同域的API时:

css 复制代码
// 前端代码 (运行在 https://myapp.com)
fetch('https://api.otherservice.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  body: JSON.stringify({ name: '张三', age: 30 })
})

这段代码会触发一个OPTIONS预检请求,然后才是实际的POST请求。

2. 携带自定义请求头的POST请求

即使是同源请求,如果添加了自定义请求头,也会触发预检:

css 复制代码
fetch('/api/local', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Custom-Header': '自定义值' // 自定义头部会触发预检
  },
  body: JSON.stringify({ data: '测试数据' })
})

3. 使用非简单内容类型的请求

当使用除了以下内容类型之外的其他类型时,也会触发预检请求:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

例如,使用JSON格式:

css 复制代码
axios.post('/api/data', 
  { userId: 123 },
  { headers: { 'Content-Type': 'application/json' } }
)

4. WebSocket连接建立过程中的预检

在建立WebSocket连接时,如果是跨域的,也会先发送一个OPTIONS请求:

ini 复制代码
const socket = new WebSocket('wss://api.otherservice.com/socket');

三、如何识别是否为预检请求

1. 网络面板中的请求特征

在Chrome开发者工具的Network面板中,预检请求有明显特征:

  • 请求方法显示为OPTIONS
  • 状态码通常为200或204
  • 请求大小通常很小,因为不包含实际数据

![网络面板示例]

2. OPTIONS方法与实际POST的关系

OPTIONS请求总是先于实际的POST请求发送,如果OPTIONS请求失败,浏览器将不会发送后续的实际请求。这是一种"先问后做"的安全机制。

3. 预检请求的响应头分析

成功的预检请求响应通常包含以下头部:

yaml 复制代码
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

其中Access-Control-Max-Age指定了预检请求的缓存时间,单位为秒。

四、解决双重POST请求的策略

1. 服务端正确配置CORS响应头

在服务端正确配置CORS头部是最基本的解决方案:

javascript 复制代码
// Node.js Express示例
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://myapp.com');
  res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.header('Access-Control-Max-Age', '86400'); // 24小时缓存预检结果
  
  // 对OPTIONS请求直接返回200
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200);
  }
  
  next();
});

2. 使用简单请求规避预检

如果可能,使用简单请求可以避免预检:

go 复制代码
// 使用表单数据而非JSON
const formData = new FormData();
formData.append('name', '张三');
formData.append('age', '30');

fetch('/api/data', {
  method: 'POST',
  body: formData // 不需要设置Content-Type
})

3. 预检请求缓存机制的利用

通过设置较长的Access-Control-Max-Age,可以减少预检请求的频率:

arduino 复制代码
// 服务端设置
res.header('Access-Control-Max-Age', '86400'); // 24小时

这样,在缓存期内,浏览器不会重复发送预检请求。

4. 代理服务器解决跨域问题

在开发环境中,可以使用代理服务器转发请求,避免跨域:

java 复制代码
// webpack开发服务器配置
module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.otherservice.com',
        changeOrigin: true,
        pathRewrite: { '^/api': '' }
      }
    }
  }
};

五、常见框架中的POST请求处理

1. React中的Axios请求配置

在React应用中,可以配置Axios全局处理CORS相关问题:

javascript 复制代码
// axios配置
import axios from 'axios';

const ts = axios.create({
  baseURL: 'https://api.service.com',
  timeout: 5000
});

// 请求拦截器
ts.interceptors.request.use(config => {
  // 避免OPTIONS请求携带认证信息
  if (config.method.toLowerCase() !== 'options') {
    config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
  }
  return config;
});

export default ts;

2. Vue项目中的请求拦截器设置

Vue项目中也可以类似配置:

arduino 复制代码
// vue项目中的请求配置
import axios from 'axios';

const ts = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  timeout: 10000
});

// 在请求拦截器中处理
ts.interceptors.request.use(config => {
  // 简单请求不需要预检
  if (config.method === 'post') {
    // 对于某些接口使用表单格式而非JSON
    if (config.url.includes('/simple-endpoint')) {
      config.headers['Content-Type'] = 'application/x-www-form-urlencoded';
      // 转换数据格式
      const formData = new URLSearchParams();
      for (const key in config.data) {
        formData.append(key, config.data[key]);
      }
      config.data = formData;
    }
  }
  return config;
});

3. Express后端的CORS中间件

在Express后端,可以使用专门的CORS中间件:

javascript 复制代码
const express = require('express');
const cors = require('cors');
const app = express();

// CORS配置
const corsOptions = {
  origin: 'https://myapp.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400 // 预检请求缓存时间
};

// 应用CORS中间件
app.use(cors(corsOptions));

// 路由定义
app.post('/api/data', (req, res) => {
  // 处理POST请求
  res.json({ success: true });
});

app.listen(3000, () => {
  console.log('服务器运行在3000端口');
});

六、双重POST带来的性能影响

1. 网络延迟与用户体验

每个预检请求都会增加额外的网络往返时间:

  • 一次普通请求:客户端 → 服务器 → 客户端
  • 带预检的请求:客户端 → 服务器(OPTIONS) → 客户端 → 服务器(POST) → 客户端

这种额外的网络延迟在网络条件不佳时尤为明显,可能导致用户体验下降。

2. 服务器额外负载分析

预检请求会增加服务器的请求处理量:

scss 复制代码
// 假设每分钟有100个POST请求
无预检情况:100个请求/分钟
有预检情况:200个请求/分钟(100个OPTIONS + 100个POST)

对于高流量网站,这可能导致服务器负载显著增加。

3. 移动端环境下的特殊考量

在移动网络环境下,额外的网络往返尤其昂贵:

  • 增加电池消耗
  • 在弱网环境下可能导致请求超时
  • 增加用户流量消耗

因此,在移动应用中应特别注意减少不必要的预检请求。

七、调试与排查双重POST问题的工具

1. Chrome开发者工具的高级用法

Chrome开发者工具提供了强大的功能来分析网络请求:

  • 使用Network面板过滤OPTIONS请求:method:OPTIONS
  • 查看请求头和响应头详情
  • 使用"Disable cache"选项确保不受缓存影响
  • 使用"Preserve log"选项在页面跳转后保留请求记录

2. Fiddler/Charles抓包分析

Fiddler和Charles等代理工具可以更深入地分析请求:

ini 复制代码
// Fiddler过滤器示例
req.method == "OPTIONS" || (req.method == "POST" && req.url.contains("api"))

这些工具还可以修改请求头进行测试,帮助诊断问题。

3. Postman与浏览器行为的差异

值得注意的是,Postman等API测试工具不遵循浏览器的同源策略,因此不会发送预检请求:

dart 复制代码
// Postman中直接发送的请求
POST https://api.otherservice.com/data
Content-Type: application/json
Authorization: Bearer token123

{
  "name": "张三",
  "age": 30
}

这种差异有时会导致在Postman中能工作但在浏览器中失败的情况。

4. 服务端日志分析技巧

服务端日志分析可以帮助发现预检请求相关问题:

javascript 复制代码
// Express日志中间件示例
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} | ${req.method} ${req.url}`);
  if (req.method === 'OPTIONS') {
    console.log('收到预检请求,响应头:', JSON.stringify(res.getHeaders()));
  }
  next();
});

通过分析日志,可以确认预检请求是否正确处理,以及实际请求是否到达服务器。

相关推荐
我不是混子几秒前
如何实现数据脱敏?
java·后端
小松XXS10 分钟前
elasticsearch面试八股文
大数据·elasticsearch·面试
野犬寒鸦18 分钟前
今日面试之项目拷打:锁与事务的深度解析
java·服务器·数据库·后端
ajassi200034 分钟前
开源 java android app 开发(十五)自定义绘图控件--仪表盘
android·java·开源
FrankYoou37 分钟前
Spring Boot 自动配置之 TaskExecutor
java·spring boot
爱读源码的大都督38 分钟前
Spring AI Alibaba JManus底层实现剖析
java·人工智能·后端
间彧1 小时前
ReentrantLock与ReadWriteLock在性能和使用场景上有什么区别?
java
Lbwnb丶1 小时前
p6spy 打印完整sql
java·数据库·sql
间彧1 小时前
公平锁与非公平锁的选择策略与场景分析
java
渣哥1 小时前
锁升级到底能不能“退烧”?synchronized 释放后状态解析
java