你有没有遇到过这样的情况:前端传过来的JSON数据明明在Postman里测试得好好的,一到Spring Boot应用就报错?控制台里赫然躺着HttpMessageNotReadableException这个刺眼的异常,提示什么"Unexpected character"?这种情况十有八九是你的JSON里藏了特殊字符。
我最近就踩过这样的坑。当时对接一个第三方支付接口,他们的返回数据里总喜欢用ASCII 160这个特殊的空格字符(就是HTML里的 )。正常空格是ASCII 32,而160这个"顽固分子"看起来和普通空格一模一样,但JSON解析器可不认账。Spring MVC的默认配置下,这种字符就像混入晚宴的不速之客,直接触发HttpMessageNotReadableException,让整个请求处理流程戛然而止。
这种问题的棘手之处在于,肉眼根本看不出区别。你可能在IDE里看到的是这样的JSON:
json复制{
payment_id: "12345" // 注意这个冒号前的"空格"
}
但实际上,这个"空格"可能是ASCII 160。用hexdump工具查看原始字节流,你会发现它其实是\xA0而不是\x20。这就是为什么Jackson解析器会暴跳如雷——按照JSON规范,字段名必须用双引号包裹,而它却遇到了一个"不明字符"。
当你的Spring Boot应用收到一个HTTP请求时,最先接触原始数据的是Servlet容器(比如Tomcat)。这时候的数据还是纯字节流,没有任何字符编码的概念。关键转折点发生在HttpServletRequest.getInputStream()这一步,这里决定了字节流如何转换为字符流。
我曾经用Wireshark抓包分析过一个异常案例,发现容器默认会用ISO-8859-1编码来解读字节流。如果客户端实际用的是UTF-8,就可能出现字符错乱。这就是为什么我们总强调要在Content-Type头里明确指定charset:
http复制POST /api/payment HTTP/1.1
Content-Type: application/json;charset=UTF-8
当字节流变成字符流后,Spring的HttpMessageConverter开始登场。对于JSON数据,默认使用的是MappingJackson2HttpMessageConverter。这个阶段最容易出问题的就是字符编码转换。我曾在处理中文数据时遇到过经典的三字节变两字节问题,导致整个JSON结构崩坏。
Jackson解析器的工作流程可以简化为:
JsonParser实例当遇到ASCII 160这种特殊字符时,流程在第二步就会抛出JsonParseException,然后被Spring包装成我们熟悉的HttpMessageNotReadableException。
遇到紧急生产问题怎么办?这里分享一个我常用的"急救包":
java复制@ControllerAdvice
public class JsonExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<String> handleJsonException(HttpMessageNotReadableException ex) {
if (ex.getCause() instanceof JsonParseException) {
JsonParseException jpe = (JsonParseException) ex.getCause();
return ResponseEntity.badRequest().body("JSON解析错误: " + jpe.getOriginalMessage());
}
return ResponseEntity.badRequest().body("请求体格式错误");
}
}
这个全局异常处理器至少能让API返回友好的错误信息,而不是500堆栈跟踪。
对于顽固的特殊字符问题,我推荐在消息转换前进行过滤。下面是我在金融项目中实际使用的方案:
java复制public class SanitizingHttpMessageConverter extends MappingJackson2HttpMessageConverter {
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException {
InputStream inputStream = inputMessage.getBody();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8)
.replace('\u00A0', ' '); // 替换ASCII 160为普通空格
ByteArrayInputStream sanitized = new ByteArrayInputStream(body.getBytes());
HttpInputMessage sanitizedMessage = new HttpInputMessage() {
// 实现相关接口方法
};
return super.readInternal(clazz, sanitizedMessage);
}
}
记得在Spring配置中替换默认的转换器:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.removeIf(c -> c instanceof MappingJackson2HttpMessageConverter);
converters.add(new SanitizingHttpMessageConverter());
}
}
在微服务架构下,我建议采用分层验证策略:
@Valid注解配合Jackson的FAIL_ON_UNKNOWN_PROPERTIES一个实用的DTO配置示例:
java复制@Data
public class PaymentRequest {
@NotNull
@Pattern(regexp = "^[\\x20-\\x7E]+$") // 只允许可打印ASCII字符
private String paymentId;
@JsonCreator
public PaymentRequest(@JsonProperty("payment_id") String paymentId) {
this.paymentId = paymentId.replace('\u00A0', ' ');
}
}
在生产环境中,我推荐配置专门的监控指标:
java复制@Bean
public MeterRegistryCustomizer<MeterRegistry> jsonExceptionMetrics() {
return registry -> Counter.builder("json.parse.errors")
.description("JSON解析错误计数")
.tag("exception", "HttpMessageNotReadableException")
.register(registry);
}
配合Grafana看板,可以实时掌握系统健康状况。
字符编码问题远不止ASCII 160这么简单。在我的开发生涯中,遇到过这些典型场景:
处理这些问题的黄金法则是:尽早统一编码,尽早清洗数据。我常用的工具方法是:
java复制public class StringUtils {
public static String cleanInvisibleChars(String input) {
return input.replaceAll("[\\u00A0\\u200B\\uFEFF]", "");
}
}
完善的测试是防御编码问题的最后防线。我的测试套件通常包括:
java复制@Test
void shouldHandleNonBreakingSpace() {
String json = "{\"name\":\"value\u00A0\"}";
assertDoesNotThrow(() -> objectMapper.readValue(json, Map.class));
}
java复制@Test
void shouldRejectInvalidJson() {
mockMvc.perform(post("/api")
.contentType(MediaType.APPLICATION_JSON)
.content("{bad:\u00A0json}"))
.andExpect(status().isBadRequest());
}
在处理特殊字符时,我们需要警惕过度清洗带来的性能问题。曾经有个项目因为添加了太多正则校验,导致API延迟增加了300%。经过压测优化后,我总结出这些经验:
安全方面,要特别注意这些风险点:
一个平衡安全与性能的配置示例:
java复制@Bean
public Jackson2ObjectMapperBuilder objectMapperBuilder() {
return new Jackson2ObjectMapperBuilder()
.failOnUnknownProperties(true)
.featuresToEnable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.featuresToDisable(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES)
.timeZone(TimeZone.getDefault());
}
每次解决HttpMessageNotReadableException这样的问题,都是优化系统架构的机会。在我主导的最近一次架构升级中,我们实现了:
这些改进使系统的可用性从99.5%提升到了99.95%。关键代码结构如下:
java复制public class ApiError {
private String code;
private String message;
private String traceId;
public static ApiError from(Exception ex) {
if (ex instanceof HttpMessageNotReadableException) {
return new ApiError("INVALID_JSON", "请求体格式错误", MDC.get("traceId"));
}
// 其他异常处理...
}
}
在微服务环境下,我们还建立了跨服务的错误传播机制,确保前端能获得一致的错误体验。