问题
由于参与的项目有出海需求,即需要给外国人使用,即:需要支持i18n(Internationalization的缩写,共20个字母,除去首尾两个字母,中间有18个,故简称i18n)。
本来是好的,非常简单的Controller接口,在增加字段后,突然爆出StackOverflowError异常:
org.springframework.web.util.NestedServletException: Handler dispatch failed; nested exception is java.lang.StackOverflowError
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1087)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:555)
排查
直接请教ChatGPT,给出如下几种可能导致Controller层接口StackOverflowError异常的场景:
- Controller方法导致无限递归:直接或间接调用自己
- JSON序列化导致无限递归:Controller返回的对象包含双向引用,Jackson序列化时可能会陷入无限递归。
@Data
@Entity
public class User {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Order> orders;
}
@Data
@Entity
public class Order {
@Id
private Long id;
private String product;
@ManyToOne
private User user; // 双向引用
}
当User关联Order,Order也关联User,Jackson解析时会无限嵌套,导致StackOverflowError。
解决方案
使用@JsonManagedReference和@JsonBackReference解决循环引用:
@Data
@Entity
public class User {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "user")
@JsonManagedReference
private List<Order> orders;
}
@Data
@Entity
public class Order {
@Id
private Long id;
private String product;
@ManyToOne
@JsonBackReference
private User user;
}
或使用@JsonIgnore:
@ManyToOne
@JsonIgnore
private User user;
- AOP拦截器陷入无限调用:错误使用Spring AOP或拦截器,可能会导致方法被无限调用;
- 错误的
toString()
方法:实体类toString()
方法递归调用自身字段。
四种可能性,第二种可能性最大;于是将问题定位到JSON序列化上。
我的Controller层接口,返回对象(即responseBody)并没有包含双向引用;于是将注意力转移到接口的requestBody上。
定位
经过分析,是我在@RequestBody标注的Dto实体类里新增一个Locale字段:
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
@ApiModel(description = "消息创建请求体")
public class MessageCreateDto {
private Locale locale;
}
去掉这个字段,就不再有StackOverflowError异常。
方案
定位到问题后,怎么解决问题呢?
还是直接请教ChatGPT,给出的分析:
Locale本身不是一个普通的Java Bean,它没有默认的无参构造方法,并且Jackson可能无法正确解析它,导致JSON反序列化时进入递归调用,最终导致StackOverflowError。
给出的几种解决方案:
方案一:使用@JsonCreator和@JsonValue
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
public class MessageCreateDto {
private Locale locale;
@JsonCreator
public static Locale fromString(String value) {
// 解析 "en_US" => Locale("en", "US")
return Locale.forLanguageTag(value.replace('_', '-'));
}
@JsonValue
public String toJson() {
// 让 Jackson 以 "en-US" 格式序列化
return locale.toLanguageTag();
}
}
经过验证:并没有解决问题。
方案二:自定义Jackson反序列化器
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
public class LocaleDeserializer extends JsonDeserializer<Locale> {
@Override
public Locale deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String localeStr = p.getText();
return Locale.forLanguageTag(localeStr.replace('_', '-'));
}
}
然后在实体类加上注解:
@JsonDeserialize(using = LocaleDeserializer.class)
@JsonSerialize(using = LocaleSerializer.class)
private Locale locale;
经过验证:并没有解决问题。
再加上序列化器,还是有StackOverflowError异常。
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
public class LocaleSerializer extends JsonSerializer<Locale> {
@Override
public void serialize(Locale locale, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (locale != null) {
// 格式化为 "en-US" 这种形式,而不是 "en_US"
gen.writeString(locale.toLanguageTag());
} else {
gen.writeNull();
}
}
}
方案三:手动转换String
public class MessageCreateDto {
private String projectId;
// 直接使用Locale类型,Jackson反序列化报错StackOverflowError
private String locale;
public Locale getLocale() {
return Locale.forLanguageTag(locale.replace('_', '-'));
}
}
经过验证,此方案可行。
反思
上面的方案三虽然可以解决问题。
但是!!
遗留问题:如果我有5个Java应用,每个应用都有20~30个不等的Controller层接口需要支持i18n,那我得在每个应用,每个Controller层接口里加上String locale
字段,然后再增加一个getLocale
方法么?
稍微熟悉HTTP,或有一点点前端开发经验,或使用F12快捷键查看过Chrome控制台,就知道有个accept-language
HTTPheader:
因此,不是加字段,而是:
- 前端在全局配置文件里设置
accept-language
; - 所有请求接口里自动带上此header;
- 后端接口按需解析HTTP header,然后做i18n处理。
题外话
zh_CN和zh-CN
关于Locale,到底是使用下划线(即,_
)还是横杠(即,-
,专业说法,其实是减号
,连字符
)
参考zh-CN还是zh_CN。
另外Java API也该出它的立场,zh_CN
是老式写法,zh-CN
才是推荐的写法。如下图所示,有一个replace
动作:
Locale.US.toString()和Locale.US.toLanguageTag()
前者是老式写法(中间有空格),后者是新式写法:
参考
- ChatGPT