POST 请求用法详解

以 Flutter Dio 为例,系统讲解 POST 请求中 body、query、headers、path、form 和 multipart 的使用方式。

随笔HTTPPOST

在 Flutter 项目中使用 Dio 发起 POST 请求时,数据并不只有一种携带方式。常见位置包括:请求体 data、URL 查询参数 queryParameters、请求头 headers、路径参数 path、表单 body,以及用于文件上传的 multipart/form-data

本文以 Dio 为例,说明这些 POST 请求的写法、客户端实际发出去的 HTTP 大概长什么样,以及服务端一般会从哪里取到这些数据。

基础结构

Dio 的 POST 请求通常长这样:

final dio = Dio(BaseOptions(
  baseUrl: 'https://example.com',
));

final response = await dio.post(
  '/api/users',
  data: {},
  queryParameters: {},
  options: Options(
    headers: {},
  ),
);

几个关键参数的含义是:

Dio 参数HTTP 位置服务端常见读取方式
data请求体 bodyreq.body
queryParametersURL 后面的 ?a=1&b=2req.query
options.headers请求头 headersreq.headers
path 字符串URL 路径req.params
FormDatamultipart bodyreq.body / req.file

简单记忆:

data              -> body
queryParameters   -> URL query
Options.headers   -> headers
FormData          -> multipart 文件上传
路径字符串插值      -> path params

1. JSON Body

现代 API 最常见的 POST 方式,是把业务数据放在 JSON body 里。

final response = await dio.post(
  '/api/users',
  data: {
    'name': 'Alice',
    'age': 18,
    'email': 'alice@example.com',
  },
  options: Options(
    contentType: Headers.jsonContentType,
  ),
);

客户端实际发出去大概是:

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "name": "Alice",
  "age": 18,
  "email": "alice@example.com"
}

服务端通常收到:

req.body
// {
//   name: "Alice",
//   age: 18,
//   email: "alice@example.com"
// }

适合放在 JSON body 的内容包括:

  • 登录、注册信息
  • 创建订单参数
  • 提交表单数据
  • 嵌套对象
  • 数组列表
  • 复杂筛选条件

例如创建订单:

await dio.post(
  '/api/orders',
  data: {
    'userId': 123,
    'items': [
      {'productId': 1, 'quantity': 2},
      {'productId': 2, 'quantity': 1},
    ],
    'address': {
      'city': 'Shanghai',
      'street': 'Road 1',
    },
  },
);

对应 HTTP body:

{
  "userId": 123,
  "items": [
    { "productId": 1, "quantity": 2 },
    { "productId": 2, "quantity": 1 }
  ],
  "address": {
    "city": "Shanghai",
    "street": "Road 1"
  }
}

2. URL Query 参数

POST 请求也可以在 URL 后面携带 query 参数。Dio 中使用 queryParameters

await dio.post(
  '/api/search',
  queryParameters: {
    'page': 1,
    'pageSize': 20,
    'sort': 'createdAt',
  },
  data: {
    'keyword': 'phone',
    'brand': 'Apple',
  },
);

客户端实际请求大概是:

POST /api/search?page=1&pageSize=20&sort=createdAt HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "keyword": "phone",
  "brand": "Apple"
}

服务端通常会分开收到:

req.query
// {
//   page: "1",
//   pageSize: "20",
//   sort: "createdAt"
// }

req.body
// {
//   keyword: "phone",
//   brand: "Apple"
// }

注意,query 参数在很多服务端框架里会先被解析成字符串。例如 page=1 通常先是字符串 "1",不是数字 1

query 参数适合放:

  • 分页参数:pagepageSize
  • 排序参数:sortorder
  • 简单筛选开关
  • 来源标记:source=app
  • 调试或行为控制参数

不适合放:

  • 密码
  • token
  • 手机号、身份证等敏感信息
  • 大段文本
  • 复杂 JSON

原因是 query 会出现在 URL 中,可能被浏览器历史、网关日志、服务端日志记录下来,而且 URL 长度也有限制。

3. Form Urlencoded Body

有些老接口、OAuth 接口或传统表单接口要求 application/x-www-form-urlencoded

Dio 写法:

await dio.post(
  '/login',
  data: {
    'username': 'alice',
    'password': '123456',
  },
  options: Options(
    contentType: Headers.formUrlEncodedContentType,
  ),
);

客户端实际发出去:

POST /login HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded

username=alice&password=123456

服务端收到:

req.body
// {
//   username: "alice",
//   password: "123456"
// }

它看起来很像 URL query,但位置不同:

POST /login?username=alice&password=123456

这是 query 参数。

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=alice&password=123456

这是 body 参数。

关键区别在 Dio 的 contentType

Options(
  contentType: Headers.formUrlEncodedContentType,
)

常见使用场景:

  • OAuth token 接口
  • 传统 Java、PHP 后端表单接口
  • 兼容 HTML form 风格的接口

4. Multipart FormData 上传文件

上传文件时通常使用 FormDataMultipartFile

final formData = FormData.fromMap({
  'username': 'alice',
  'avatar': await MultipartFile.fromFile(
    file.path,
    filename: 'avatar.png',
  ),
});

final response = await dio.post(
  '/api/upload',
  data: formData,
);

客户端实际发出去大概是:

POST /api/upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----dio-boundary

------dio-boundary
Content-Disposition: form-data; name="username"

alice
------dio-boundary
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png

<文件二进制内容>
------dio-boundary--

服务端通常收到:

req.body
// {
//   username: "alice"
// }

req.file
// {
//   fieldname: "avatar",
//   originalname: "avatar.png",
//   mimetype: "image/png",
//   size: 12345
// }

多个文件可以这样写:

final formData = FormData();

formData.fields.add(const MapEntry('title', 'photos'));
formData.files.add(MapEntry(
  'files',
  await MultipartFile.fromFile('/path/a.png', filename: 'a.png'),
));
formData.files.add(MapEntry(
  'files',
  await MultipartFile.fromFile('/path/b.png', filename: 'b.png'),
));

await dio.post('/api/photos', data: formData);

使用 FormData 时,Dio 会自动处理 Content-Type 和 boundary。一般不要手动写死 multipart 的 boundary。

5. Headers 请求头

Header 通常用于携带请求元信息,而不是业务主体数据。

await dio.post(
  '/api/orders',
  data: {
    'productId': 1001,
    'quantity': 2,
  },
  options: Options(
    headers: {
      'Authorization': 'Bearer your_token',
      'X-Request-Id': 'req-001',
      'X-Client-Version': '1.0.0',
    },
  ),
);

客户端实际请求:

POST /api/orders HTTP/1.1
Host: example.com
Authorization: Bearer your_token
X-Request-Id: req-001
X-Client-Version: 1.0.0
Content-Type: application/json

{
  "productId": 1001,
  "quantity": 2
}

服务端读取:

req.headers.authorization
// "Bearer your_token"

req.headers['x-request-id']
// "req-001"

req.body
// {
//   productId: 1001,
//   quantity: 2
// }

如果 token 每个请求都要带,不建议每次都手写 Options,可以放在 Dio 初始化中:

final dio = Dio(BaseOptions(
  baseUrl: 'https://example.com',
  headers: {
    'Authorization': 'Bearer your_token',
  },
));

更常见的是通过拦截器动态添加:

dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) {
      options.headers['Authorization'] = 'Bearer your_token';
      handler.next(options);
    },
  ),
);

header 适合放:

  • Authorization
  • Content-Type
  • Accept
  • 客户端版本
  • 语言
  • 设备信息
  • 请求 ID
  • 签名

不适合放订单详情、用户资料、商品列表等业务主体数据。

6. Path 参数

Path 参数是 URL 路径本身的一部分,通常用于表示资源 ID。

final userId = 123;

await dio.post(
  '/api/users/$userId/orders',
  data: {
    'productId': 1001,
    'quantity': 2,
  },
);

客户端实际请求:

POST /api/users/123/orders HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "productId": 1001,
  "quantity": 2
}

服务端通常收到:

req.params
// {
//   userId: "123"
// }

req.body
// {
//   productId: 1001,
//   quantity: 2
// }

常见例子:

await dio.post('/api/videos/$videoId/like');

await dio.post(
  '/api/users/$userId/avatar',
  data: formData,
);

await dio.post(
  '/api/orders/$orderId/cancel',
  data: {
    'reason': 'user_cancel',
  },
);

path 适合表示明确资源:

  • 用户 ID
  • 订单 ID
  • 视频 ID
  • 某个资源下的子操作

不适合放复杂筛选条件、长文本、敏感信息。

Flutter App 不像浏览器那样天然维护 Cookie。你可以手动放在 header 里,也可以接入 cookie 管理库。

手动携带 Cookie:

await dio.post(
  '/api/profile',
  data: {
    'nickname': 'Alice',
  },
  options: Options(
    headers: {
      'Cookie': 'sessionId=abc123; theme=dark',
    },
  ),
);

客户端实际请求:

POST /api/profile HTTP/1.1
Host: example.com
Cookie: sessionId=abc123; theme=dark
Content-Type: application/json

{
  "nickname": "Alice"
}

服务端读取:

req.cookies
// {
//   sessionId: "abc123",
//   theme: "dark"
// }

req.body
// {
//   nickname: "Alice"
// }

在移动 App 中,更常见的做法是使用:

Authorization: Bearer token

Cookie 更常见于浏览器 Web 应用。

8. 混合使用

实际项目中经常会同时使用 path、query、body 和 header。

final userId = 123;

final response = await dio.post(
  '/api/users/$userId/orders',
  queryParameters: {
    'source': 'app',
    'debug': false,
  },
  data: {
    'productId': 1001,
    'quantity': 2,
    'couponCode': 'NEW_USER',
  },
  options: Options(
    headers: {
      'Authorization': 'Bearer token',
      'X-Request-Id': 'req-001',
    },
  ),
);

客户端实际请求:

POST /api/users/123/orders?source=app&debug=false HTTP/1.1
Host: example.com
Authorization: Bearer token
X-Request-Id: req-001
Content-Type: application/json

{
  "productId": 1001,
  "quantity": 2,
  "couponCode": "NEW_USER"
}

服务端对应读取:

req.params
// { userId: "123" }

req.query
// { source: "app", debug: "false" }

req.headers.authorization
// "Bearer token"

req.body
// {
//   productId: 1001,
//   quantity: 2,
//   couponCode: "NEW_USER"
// }

9. 实际开发中的选择规则

推荐按下面的规则选择:

场景推荐方式Dio 写法
主要业务数据JSON bodydata: {...}
分页、排序、简单开关URL queryqueryParameters: {...}
资源 IDpath'/users/$userId'
token、客户端版本、请求 IDheaderOptions(headers: {...})
文件上传multipartFormData + MultipartFile
传统表单接口urlencoded bodycontentType: Headers.formUrlEncodedContentType
浏览器式会话Cookieheaders: {'Cookie': ...}

10. 常见误区

POST 不代表数据只能放 body

POST 请求可以同时有 query、body 和 header。例如:

await dio.post(
  '/api/search',
  queryParameters: {'page': 1},
  data: {'keyword': 'Flutter'},
);

实际是:

POST /api/search?page=1 HTTP/1.1

{"keyword":"Flutter"}

query 和 form body 长得像,但位置不同

query 在 URL 上:

/api/login?username=alice&password=123456

form body 在请求体中:

username=alice&password=123456

Dio 中分别对应:

queryParameters: {'username': 'alice'}

和:

data: {'username': 'alice'},
options: Options(contentType: Headers.formUrlEncodedContentType),

敏感信息不要放 URL

即使是 HTTPS,URL path 和 query 也更容易出现在日志、埋点、代理记录和错误报告里。密码、token、身份证号等敏感信息不要放 query 或 path。

FormData 不等于普通 JSON

FormData 是 multipart 格式,适合文件上传。普通业务对象优先用 JSON body,不需要为了传对象就用 FormData

总结

Dio 的 POST 请求可以这样理解:

await dio.post(
  '/api/users/$userId/orders', // path 参数
  queryParameters: {},         // URL query
  data: {},                    // body
  options: Options(
    headers: {},               // header
  ),
);

最常见的组合是:

await dio.post(
  '/api/orders',
  data: {
    'productId': 1001,
    'quantity': 2,
  },
  options: Options(
    headers: {
      'Authorization': 'Bearer token',
    },
  ),
);

判断数据该放哪里时,优先问自己三个问题:

  • 这是业务主体数据吗?是的话放 data
  • 这是分页、排序、筛选开关吗?通常放 queryParameters
  • 这是认证、设备、版本、追踪 ID 吗?通常放 headers

如果涉及文件上传,再使用 FormDataMultipartFile