qinghuan
← Java Web / 全部笔记

单元 34 / Java Web

P0 · 必会主线L1 · 基础

表单与 JSON:同一组字段为什么发送结果不同

查看浏览器真正发出的格式和字段,分清表单与 JSON,再让 Content-Type、字段名和服务端读取方式对应。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能通过 Network 对比表单和 JSON,并让请求格式与绑定方式一致。

学习理由、准备要求与知识点

为什么学它

绑定错误常来自发送格式与服务端读取位置不一致,需要沿字节到 DTO 核对。

先读这些内容

知识点清单

  • name/id/label 职责
  • 可提交控件规则
  • GET 与 POST 表单
  • urlencoded 与 multipart
  • JSON 语法和值类型
  • Content-Type 对解析的影响
  • RequestBody 与请求参数
  • 缺失、null 与空字符串

运行前先确认浏览器表单与 JSON 按当前标准解释;Java 源码为 OpenJDK 17u jdk-17.0.16+8 URLDecoder。算法使用 Java 17,解析受限 UTF-8 application/x-www-form-urlencoded 文本并保留重复字段,不是 JSON、multipart 或通用 URL 解析器。

01先弄懂它是什么

用户名和标签这组字段,可以放在 URL 查询、普通表单、JSON 正文或 multipart 消息中,服务端的读取方式也不同。差别在于怎样区分字段、表示类型和重复值,以及怎样编码字符。服务端要按数据位置和 Content-Type 选择解析方法,不能把一段 JSON 交给表单读取接口,就期待自动得到同样的对象。

普通表单编码常用 & 分隔字段、首个 = 分隔名称和值,空格通常写成 +,其他字节可用百分号编码。这套 + 的规则不能直接套到所有 URI 路径上。JSON 则直接区分字符串、数字、真假、null、数组和对象,所以数字 1 与字符串“1”不是同一个值。发送格式与服务端期待不一致时,绑定结果也会不同。

以 tag=java&tag=web&name=qing+huan 为例,tag 是两个值,name 解码后带空格。若过早放入 Map<String,String>,可能保留首项、末项或拼接结果,业务意义随实现改变。先保留多值和顺序,再由 DTO 绑定决定接受集合还是拒绝重复字段,能避免权限参数等敏感输入被不同层解释为不同值。

文中这几个词,先认一认

Content-Type
说明当前请求或响应正文是什么格式,帮助接收方选择怎样解析它。
Accept
告诉服务端客户端希望收到哪些响应格式,不会把当前发送的正文自动转换成那些格式。
百分号编码
用 % 加两位十六进制表示一个字节,连续字节再按约定字符集还原成文字。
多值参数
同一个参数名出现多个值,例如多个 tag;要明确保留全部、选一个还是拒绝重复。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

正确的表单解析顺序是先按编码层的分隔符切分字段,再分别解码名称和值。若先把整段正文解码,原本属于值的 %26 会变成与号,再被错误识别成新的字段。第一处等号决定名称和值边界,后续等号仍属于值;这些规则都应在手工可核对的例子中验证。

百分号后的数字描述字节,不是直接描述 Java 字符。中文需要先得到连续 UTF-8 字节,再一次按字符集解码。语法非法的 %G1 与语法合法但 UTF-8 不完整的 %C3 是两类错误;某些通用 API 对前者抛异常,对后者可能产生替代字符。服务端若要求严格文本,应单独约定并检查字符集错误。

JSON 解析路径不使用表单的加号空格转换,multipart 则依靠边界参数划分各部分,文件内容更不能当成普通字符串随意处理。收到不支持的媒体类型、正文格式错误或字段约束失败时,响应语义也应区分。请求头只是声明,解析器仍需验证实际内容与容量,不能因为头写了 JSON 就相信正文一定合法。

正在加载流程图…

字段分隔先于组件解码;媒体类型选择、语法解析和业务校验是不同阶段。
查看流程图源码(Mermaid)
flowchart TD
 A["读取 Content-Type 与正文位置"] --> B{"选择解析协议"}
 B -->|"表单"| C["先切分字段与名称和值"]
 C --> D["分别做百分号和字符集解码"]
 D --> E["保留多值参数"]
 B -->|"JSON"| F["JSON 语法与类型解析"]
 B -->|"multipart"| G["按边界解析各部分"]
 E --> H["DTO 绑定与业务约束"]
 F --> H
 G --> H

按顺序看,每一步做了什么

  1. 记录数据位于查询、普通正文还是 multipart 部分。
  2. 按 Content-Type 选择解析器并限制大小。
  3. 保留原协议定义的字段边界与重复项,再进行组件解码。
  4. 绑定到明确类型后验证必填、范围、格式和重复字段政策。

03对照实现代码

URLDecoder 如何处理加号与连续百分号字节 (新标签页)↗

OpenJDK 17u,jdk-17.0.16+8 · java.net.URLDecoder#decode(String, Charset)

真实 Java 文件与 decode 方法已核验。下面是原创局部模型,强调表单组件中的加号和百分号语义;委托标准 API 执行字节解码,不宣称是逐行原样源码。模型不负责拆字段、验证媒体类型或严格 UTF-8。

核心机制代码 · java
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
class SourceModel {
  record Field(String name,String value) {}
  static Field oneField(String encoded) {
    int boundary=encoded.indexOf('='); // 必须在解码前找到字段结构
    String name=boundary<0?encoded:encoded.substring(0,boundary);
    String value=boundary<0?"":encoded.substring(boundary+1);
    // 标准 API 对加号与百分号序列按表单组件规则解释
    String decodedName=URLDecoder.decode(name,StandardCharsets.UTF_8);
    String decodedValue=URLDecoder.decode(value,StandardCharsets.UTF_8);
    return new Field(decodedName,decodedValue); // 不对结果进行第二次解码
  }
}

URLDecoder.decode 按字符扫描输入。普通字符可以直接保留,加号进入空格分支,百分号分支则把连续十六进制字节收集起来再按字符集构造文本。重要的是整个连续字节序列共同解释一个或多个字符,不能把每个百分号片段都单独当成 Unicode 字符。

源码会检查百分号序列是否完整以及两位字符是否构成合法十六进制。它的职责是表单组件解码,而不是完整 URI 语法解析,所以名称类似 URLDecoder 也不意味着可以对所有路径、整个 URL 或 JSON 内容无差别调用。调用场景必须与 API 契约一起读。

遇到非法编码字节,字符转换也有“替换还是报错”的选择。这里 URLDecoder 使用按字符集构造字符串的路径,可能替换坏字节,不能认为非法 UTF-8 都会严格抛错。后面算法显式使用 CharsetDecoder 的 REPORT 策略,把坏输入报告为错误;CharsetDecoder 本身的默认错误策略也是 REPORT。

源码没有决定同名字段取哪个值,也没有验证表单中的 userId 是否可信。教学模型先切分一个字段后解码,展示边界顺序;真正的绑定层还要处理多值、默认值与类型。混淆这几层会让一个解析正确的请求仍然产生越权或业务歧义。

代码先调用谁,再调用谁

  1. HTTP 层确定媒体类型和正文。
  2. 表单解析器划分字段及名称和值。
  3. URLDecoder.decode 处理单个组件。
  4. 绑定层决定多值、类型和业务约束。

04动手写一个例子

严格 UTF-8 表单解析与多值保留

输入 ASCII 表单编码字符串,返回按名称保留所有值的有序映射。空字段片段忽略,缺失等号视为空值,同名字段保留输入顺序。非 ASCII 必须以 UTF-8 百分号字节表达;非法百分号或非法 UTF-8 拒绝,正文最多 4096 字符。

输入 1
tag=java&tag=web
输出 1
tag -> [java, web]
输入 2
name=qing+huan
输出 2
name -> [qing huan]
输入 3
x=%26a%3Db
输出 3
x -> [&a=b]
输入 4
a=%C3
输出 4
IllegalArgumentException

先以与号划分原始字段,不能先解码整个正文,否则值里的编码分隔符会升级为结构。每段只找第一个等号,并将名称和值分别传给组件解码器。

组件解码时连续百分号组成字节缓冲,严格 UTF-8 解码成功后追加到结果。原始加号变空格,但 %2B 产生的加号不会再变空格;同理,解码后的 %26 也不继续做第二轮解码。

每个字段被放进 LinkedHashMap 对应的列表中,以保留名称首次出现顺序和同名值顺序。main 覆盖重复键、中文、空正文、空键、嵌入分隔符以及两类错误。测试不调用 HTTP 服务,可独立编译运行。

为什么这样写是对的结构切分只观察原始编码文本;每个组件恰好解码一次,所有同名字段按出现顺序保留。

时间 期望 O(n)额外空间 O(n)

字符被常数次扫描,哈希映射插入按通常均摊模型计,输出与临时字节缓冲线性于输入长度。恶意哈希冲突与实际解码库常数成本需另行评估。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.*;
import java.util.*;
public class Main {
  static String component(String raw) {
    StringBuilder out=new StringBuilder();
    for(int i=0;i<raw.length();) {
      char ch=raw.charAt(i);
      if(ch>127) throw new IllegalArgumentException("非 ASCII 必须编码");
      if(ch=='+') { out.append(' '); i++; continue; }
      if(ch!='%') { out.append(ch); i++; continue; }
      ByteArrayOutputStream bytes=new ByteArrayOutputStream();
      while(i<raw.length() && raw.charAt(i)=='%') {
        if(i+2>=raw.length()) throw new IllegalArgumentException("百分号不完整");
        int hi=Character.digit(raw.charAt(i+1),16),lo=Character.digit(raw.charAt(i+2),16);
        if(hi<0 || lo<0 || raw.charAt(i+1)>127 || raw.charAt(i+2)>127)
          throw new IllegalArgumentException("非法十六进制");
        bytes.write(hi*16+lo); i+=3; // 收集完整连续字节再解码
      }
      try {
        CharsetDecoder decoder=StandardCharsets.UTF_8.newDecoder()
          .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
        out.append(decoder.decode(ByteBuffer.wrap(bytes.toByteArray())));
      } catch(CharacterCodingException e) { throw new IllegalArgumentException("非法 UTF-8",e); }
    }
    return out.toString();
  }
  static Map<String,List<String>> parse(String body) {
    if(body==null || body.length()>4096) throw new IllegalArgumentException();
    Map<String,List<String>> result=new LinkedHashMap<>();
    for(String pair:body.split("&",-1)) {
      if(pair.isEmpty()) continue;
      int split=pair.indexOf('='); // 先结构切分,后组件解码
      String key=component(split<0?pair:pair.substring(0,split));
      String value=component(split<0?"":pair.substring(split+1));
      result.computeIfAbsent(key,k->new ArrayList<>()).add(value);
    }
    return result;
  }
  static void eq(Object wanted,Object actual) { if(!Objects.equals(wanted,actual)) throw new AssertionError(actual); }
  static void reject(String body) {
    try { parse(body); throw new AssertionError("应拒绝"); }
    catch(IllegalArgumentException expected) { /* 期望错误 */ }
  }
  public static void main(String[] args) {
    // 固定期望覆盖正常、空值和错误边界,失败立即中断验证
    eq(List.of("java","web"),parse("tag=java&tag=web").get("tag"));
    eq(List.of("qing huan"),parse("name=qing+huan").get("name"));
    eq(List.of("中文"),parse("x=%E4%B8%AD%E6%96%87").get("x"));
    eq(Map.of(),parse(""));
    eq(List.of("x"),parse("=x").get(""));
    eq(List.of("&a=b"),parse("x=%26a%3Db").get("x"));
    eq(List.of("%26"),parse("x=%2526").get("x"));
    reject("x=%GG"); reject("x=%C3");
    System.out.println("PASS 9 form tests");
  }
}

这些特殊情况也要试一试

  • 缺失等号与空值都可产生空字符串,但是否允许由绑定契约决定。
  • 同名敏感参数不能由网关取首值、服务取末值而形成解释差异。
  • 容量上限应在读取正文时也限制,不能等全部分配后才检查。
进阶选读 · 生产拓展:流式 multipart 与参数污染防护

场景文件上传需要处理大正文,而不同中间层可能使用不同的重复参数政策。

做法使用成熟流式解析器限制部分数量、单项与总大小,统一重复字段政策;文件名、媒体类型和目标存储权限独立验证。保持原始请求与结构化日志之间的脱敏边界。

复杂度流式处理可使工作缓冲受限,整体扫描通常随正文大小增长;文件落盘与网络成本不能省略。

好处与代价完整保留多值更透明,但业务需要额外决策;直接拒绝重复敏感字段更简单,却应与客户端契约一致。

05项目里怎么用

接口契约应明确 Content-Type、字段类型、是否允许重复项和空值。POST 只是方法,不代表正文一定是 JSON;GET 的查询也可以有重复字段。开发者工具中查看原始请求,比仅看前端对象打印结果更能确认实际发送格式。

服务端先做协议层容量限制,再做结构解析、类型绑定与业务校验。错误百分号、不合法 JSON 和手机号不合格式并非同一种失败;统一错误响应可以方便调用方,但内部应保留阶段和字段路径,避免只留下无法定位的参数错误。

JSON 放进 HTML 页面时,仍要按实际输出位置编码;表单成功解码,也不代表内容可信。当前用户应来自已验证的身份信息,不能因为 userId 成功转成 Long,就认定它是发请求的人。解析格式、检查数据和判断权限要分别完成。

容易踩的坑

  • 先解码整个正文再按与号切分,可能把值变成额外字段。
  • 把加号转空格规则应用于 JSON 或任意 URL 路径。
  • 用单值 Map 静默丢弃重复字段而没有契约。
  • 多次解码使原本的数据字符逐层变成语法符号。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
表单编码浏览器原生表单与简单字段兼容性好。类型与重复值语义需要额外绑定约定。简单表单和既有兼容接口。
JSON对象、数组与基本类型表达清楚。仍需 Schema、容量限制和严格业务校验。结构化 API 请求。
multipart适合混合字段与二进制文件。解析、容量、临时存储和安全边界更复杂。确有文件上传需求。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01Content-Type 与 Accept 有何区别?

展开参考答案与推导

前者描述当前内容类型,后者表达希望接收的表示。设置 Accept 为 JSON 不会把一个表单正文自动转换成 JSON。

02为什么先拆字段再解码?

展开参考答案与推导

编码后的值可以合法包含与号和等号。提前解码会把这些数据字符变成结构分隔符,改变字段数量和含义。

03百分号合法为什么 UTF-8 仍可能非法?

展开参考答案与推导

每个字节可以由合法十六进制表示,但字节序列未必满足 UTF-8 编码规则,例如缺少后续字节。语法检查与字符集解码是两层。

换个情况,还会处理吗

01前端传了 JSON,服务端 getParameter 得不到字段,如何查?

展开参考答案与推导

核对实际 Content-Type、请求正文和服务端绑定入口。JSON 应进入相应正文转换器,不能仅换参数名称就期待表单参数 API 解析它。

02网关与服务对重复 role 参数取值不同,有何风险?

展开参考答案与推导

同一请求可能在不同层得到不同授权含义。应统一多值语义,通常对不允许重复的敏感字段明确拒绝,并为首值末值差异写测试。

03中文乱码但 ASCII 正常,怎样定位?

展开参考答案与推导

分别记录发送字节、百分号编码、声明字符集和解码方式。不要对已经错误解码的字符串反复猜编码;应在最早的字节到字符边界修正。

写代码试一试

01解析一个值中含等号的字段。

输入 / 条件
token=a=b=c
输出 / 预期
token -> [a=b=c]

提示 只找第一处等号。

展开参考答案与推导

先定位首个结构边界,后续字符全部属于值组件,再按组件编码规则解码。

02怎样验证不会双重解码?

输入 / 条件
x=%2526
输出 / 预期
x -> [%26]

提示 不要重新遍历解码输出。

展开参考答案与推导

扫描原始输入一次并生成新结果;百分号二五生成的百分号不能再次参与当前这次解析。

03为单值字段增加重复拒绝。

输入 / 条件
age=18&age=19
输出 / 预期
明确的重复字段错误

提示 在多值解析之后做字段政策判断。

展开参考答案与推导

先完整保留两个值,再由绑定层检查该字段只允许一个值。这样协议解析器仍通用,业务也不会依赖隐式首末值选择。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能通过 Network 对比表单和 JSON,并让请求格式与绑定方式一致。

本篇重点加练

重点训练表单提交规则与缺失值语义,避免界面存在字段却没有表达预期更新。

  • 可提交控件规则
  • 缺失、null 与空字符串

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    构造表单,含只有 id 的文本框、有 name 的文本框、disabled 项、未勾选 checkbox 与两个同名项;先预测提交字段再用本地 FormData 观察。

    提交内容
    HTML 样例、预测表与实际字段记录。
    怎样算完成
    • 展示标识与提交名称分开
    • 提交规则包含各控件状态

    练到的知识点name/id/label 职责 · 可提交控件规则

  2. 作业 02原理问答

    为搜索条件、修改资料和上传头像选择表单方法与编码,说明数据进入请求的哪个位置;列出文件内容与普通文本字段分别需要的解析信息。

    提交内容
    三类请求结构图与选择理由。
    怎样算完成
    • 方法与编码各有依据
    • 敏感字段不放入未经评审的 URL

    练到的知识点GET 与 POST 表单 · urlencoded 与 multipart

综合应用

  1. 作业 03排错实验

    将同一报名信息分别编码为表单与 JSON,使用本地接收端记录媒体类型和绑定结果;再故意令头部与内容格式不一致,定位失败阶段。

    提交内容
    请求脚本、接收端配置、自写格式边界测试及运行证据。
    怎样算完成
    • 原始请求与解析结果对应
    • 失败不被假装为空参数

    练到的知识点JSON 语法和值类型 · Content-Type 对解析的影响 · RequestBody 与请求参数

  2. 作业 04设计评审

    更新个人资料时需要区分字段缺失、JSON null、空字符串以及合法非空值。为邮箱和简介分别制定四种输入的含义,明确是否允许清空。

    提交内容
    字段更新合同与验收请求集。
    怎样算完成
    • “未修改”和“清空”可区分
    • 字段政策与客户端提交方式一致

    练到的知识点缺失、null 与空字符串

  3. 作业 05编程实践

    实现表单诊断页,把输入控件收集为多值字段清单,同时生成对应 JSON 草案;重复 name 不得静默丢失,输出仅显示在本地页面。

    提交内容
    HTML/JS、自写空值/重复名/Unicode 测试与浏览器证据。
    怎样算完成
    • 多值和单值政策明确
    • 生成 JSON 保持正确值类型
    • 不发送个人信息到外部端点

    练到的知识点可提交控件规则 · urlencoded 与 multipart · JSON 语法和值类型 · 缺失、null 与空字符串

  4. 作业 06编程实践

    制作本地简介编辑表单,用户显式选择“保留、清空、替换”动作,再构造 JSON 请求预览;切换动作时输入框可能禁用,验证表单数据与最终请求都表达所选意图。

    提交内容
    HTML/JS、自写动作切换/空文本/禁用状态测试与浏览器证据。
    怎样算完成
    • 每种意图有无歧义表示
    • 原始表单字段和 JSON 合同分别核验

    练到的知识点可提交控件规则 · 缺失、null 与空字符串

  5. 作业 07设计评审

    PATCH 资料接口中,缺失手机号表示不改、null 表示清空,但用户界面使用普通表单。设计客户端编码与服务端解析之间的桥接合同。

    提交内容
    输入状态映射表与代表请求。
    怎样算完成
    • 每种业务动作都可无歧义表示
    • 不依赖后端猜用户意图

    练到的知识点缺失、null 与空字符串

  6. 作业 08面试场景

    用户可见的邮箱框被 disabled,后端收到的请求却无邮箱字段。制定排查 DOM、表单数据和原始请求的顺序,并说明哪些状态需要产品定义。

    提交内容
    证据链与交互政策。
    怎样算完成
    • 各阶段实际字段可追踪
    • 界面存在不直接当作提交证据

    练到的知识点可提交控件规则

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成 HTTP 网关基础后:网关取重复 role 参数第一项,后端取最后一项。设计一致解析政策、模糊输入拒绝规则和穿过完整请求链的验收。

    提交内容
    协议合同、差异样本与联调计划。
    怎样算完成
    • 每层解析结果有证据
    • 单值字段的重复策略明确

    练到的知识点urlencoded 与 multipart · Content-Type 对解析的影响 · RequestBody 与请求参数 · 缺失、null 与空字符串

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    相同设置页改由命令行客户端调用。比较表单控件带来的隐式规则与 JSON 请求需要显式表达的状态,为两种入口建立一致验收。

    提交内容
    双入口输入语言与共享合同测试表。
    怎样算完成
    • 控件行为不会被错误当作协议要求
    • 缺失、空和否定值可以区分

    练到的知识点可提交控件规则 · 缺失、null 与空字符串

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗