接口添加@JsonDeserialize注解引发栈溢出的原因与解决方法
场景描述
我有一个BookId接口,两个实现类ISBN和ASIN都配置了Jackson注解,能独立正常工作。这两个子类通过字段名而非字段值区分——比如{ "isbn": "xxx" }对应ISBN类,{ "asin": "xxx" }对应ASIN类。我希望序列化时不添加任何包装或额外字段,目标JSON结构如下:
{ "ids": [ { "isbn" : "978-0-596-52306-0" }, { "asin" : "B07QKFQ7QJ" } ]}
ISBN类的实现(ASIN类类似,仅验证逻辑不同):
public static class ISBN implements BookId { final String isbn; @JsonCreator public ISBN(@JsonProperty("isbn") String isbn) { if (!valid(isbn)) throw new IllegalArgumentException("bad isbn syntax"); this.isbn = isbn; } boolean valid(String isbn) { return isbn != null && !isbn.isBlank() /* && checksum ok ... */; } }
尝试的自定义反序列化器及递归问题
我给BookId接口指定了自定义反序列化器,通过判断字段名选择对应实现类,核心代码如下:
@JsonDeserialize(using = BookId.DeserializerDirectViaJackson.class) public interface BookId { class DeserializerDirectViaJackson extends StdDeserializer<BookId> { public DeserializerDirectViaJackson() { this(null); } public DeserializerDirectViaJackson(final Class<?> vc) { super(vc); } @Override public BookId deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { final var om = (ObjectMapper) jsonParser.getCodec(); final var node = (JsonNode) om.readTree(jsonParser); if (!node.isObject()) throw new IllegalStateException("expected JSON object"); if (node.has("isbn")) return om.treeToValue(node, ISBN.class); if (node.has("asin")) return om.treeToValue(node, ASIN.class); throw new IllegalStateException("expected 'isbn' or 'asin' field"); } } // ... }
但运行时出现无限递归导致栈溢出,栈追踪片段如下:
Exception in thread "main" java.lang.StackOverflowError at java.base/java.util.LinkedHashMap$LinkedEntryIterator.next(LinkedHashMap.java:788) at com.fasterxml.jackson.databind.node.TreeTraversingParser.nextToken(TreeTraversingParser.java:108) at com.fasterxml.jackson.databind.deser.std.JsonNodeDeserializer.deserialize(JsonNodeDeserializer.java:100) at com.fasterxml.jackson.databind.ObjectMapper.readTree(ObjectMapper.java:3084) at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:58) at com.fasterxml.jackson.databind.ObjectMapper.treeToValue(ObjectMapper.java:3438) at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:60) // 重复递归调用...
另外我实现了一个手动解析的反序列化器可以正常工作,但缺点是需要自己处理字段解析和验证,只适合简单场景,无法适配复杂业务:
class DeserializerHomegrown extends StdDeserializer<BookId> { public DeserializerHomegrown() { this(null); } public DeserializerHomegrown(final Class<?> vc) { super(vc); } @Override public BookId deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { final var om = (ObjectMapper) jsonParser.getCodec(); final var node = (JsonNode) om.readTree(jsonParser); if (!node.isObject()) throw new IllegalStateException("expected JSON object"); if (node.has("isbn")) return new ISBN(getValueNode(node, "isbn")); if (node.has("asin")) return new ASIN(getValueNode(node, "asin")); throw new IllegalStateException("expected 'isbn' or 'asin' field"); } String getValueNode(JsonNode node, String fieldName) { final var field = node.get(fieldName); if (field == null) return null; if (!field.isValueNode()) throw new IllegalStateException("%s field is not JSON value".formatted(fieldName)); return field.asText(); } }
原因分析
无限递归的根源是:ISBN和ASIN都实现了BookId接口,而BookId接口上标注了@JsonDeserialize(using = DeserializerDirectViaJackson.class)。当调用om.treeToValue(node, ISBN.class)时,Jackson会检查ISBN的父接口BookId的反序列化配置,发现要使用自定义的DeserializerDirectViaJackson,于是再次调用这个反序列化器的deserialize方法,形成循环调用,最终栈溢出。
解决方案
方案1:用DeserializationContext直接解析,绕过接口反序列化配置
在自定义反序列化器中,不要调用ObjectMapper.treeToValue,而是通过DeserializationContext的readValue方法,配合TreeTraversingParser解析JsonNode,绕过接口的反序列化器配置,直接解析具体实现类:
@Override public BookId deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException { ObjectMapper om = (ObjectMapper) jsonParser.getCodec(); JsonNode node = om.readTree(jsonParser); if (!node.isObject()) { throw new IllegalStateException("expected JSON object"); } if (node.has("isbn")) { // 创建针对当前节点的解析器,直接解析为ISBN类 try (JsonParser nodeParser = node.traverse(om)) { nodeParser.setCodec(om); return ctx.readValue(nodeParser, ISBN.class); } } if (node.has("asin")) { try (JsonParser nodeParser = node.traverse(om)) { nodeParser.setCodec(om); return ctx.readValue(nodeParser, ASIN.class); } } throw new IllegalStateException("expected 'isbn' or 'asin' field"); }
方案2:给实现类取消接口的反序列化配置
给ISBN和ASIN类添加@JsonDeserialize注解,指定使用默认反序列化器,覆盖接口的全局配置:
@JsonDeserialize(using = JsonDeserializer.None.class) // 取消接口的反序列化器,用默认解析 public static class ISBN implements BookId { // ... 原有代码 ... } @JsonDeserialize(using = JsonDeserializer.None.class) public static class ASIN implements BookId { // ... 原有代码 ... }
这样调用om.treeToValue(node, ISBN.class)时,Jackson会使用默认反序列化逻辑,不会触发接口的自定义反序列化器,避免递归。
方案3:适配Jackson多态反序列化的EXISTING_PROPERTY策略
其实常规Jackson多态反序列化也能适配这种场景,利用@JsonTypeInfo的EXISTING_PROPERTY策略,配合自定义反序列化器识别字段存在性:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") // 占位字段,实际靠字段存在性区分 @JsonSubTypes({ @JsonSubTypes.Type(value = ISBN.class, name = "isbn"), @JsonSubTypes.Type(value = ASIN.class, name = "asin") }) @JsonDeserialize(using = BookId.BookIdDeserializer.class) public interface BookId { class BookIdDeserializer extends StdDeserializer<BookId> { public BookIdDeserializer() { super(BookId.class); } @Override public BookId deserialize(JsonParser p, DeserializationContext ctx) throws IOException { JsonNode node = p.getCodec().readTree(p); if (node.has("isbn")) { return ctx.readValue(p, ISBN.class); } else if (node.has("asin")) { return ctx.readValue(p, ASIN.class); } throw new JsonParseException(p, "Unknown BookId type"); } } }
这种方式更贴合Jackson的多态设计规范,同时实现按字段名区分类型的需求。
内容的提问来源于stack exchange,提问作者davidbak

