最近在重构一个老项目时,我遇到了一个典型的JSON反序列化问题。原先的实体类字段定义为private String projectTypeId,前端传空字符串("")完全没问题。但当业务需求变更,需要改为集合类型private List<String> projectTypeId后,问题就来了——前端传空字符串会导致系统直接抛出异常。
这个问题的本质在于类型系统的严格性。Java的集合框架和JSON解析器对空值的处理有着不同的哲学。当Jackson(最流行的Java JSON处理器)遇到空字符串尝试转换为集合时,它会严格遵循类型约束,认为这是非法操作。就好比你拿着空矿泉水瓶去加油站说要"加满",工作人员肯定会拒绝你——因为容器类型根本不匹配。
在实际项目中,这类问题往往出现在迭代开发过程中。就像我的案例,最初设计时字段是字符串类型,后来需求变更为多值存储。但前端可能因为历史原因或不同开发者的习惯,仍然传递空字符串。这时如果粗暴地要求所有前端立即修改,不仅沟通成本高,还可能引发其他意外问题。
要真正解决这个问题,我们需要先了解Jackson处理JSON的基本流程。当收到一个JSON字符串时,Jackson会经历以下几个关键步骤:
对于集合类型,Jackson默认期望的JSON格式是数组形式(用方括号包裹)。当遇到非数组值时,它会尝试进行智能转换,这就是所谓的"强制转换"(Coercion)。但空字符串到集合的转换默认是被禁用的,因为这种转换的语义不明确——它可能表示"无元素",也可能表示"包含一个空字符串元素"。
Jackson提供了CoercionConfig来配置这类转换行为,但全局开启强制转换可能会带来其他意想不到的副作用。这就是为什么我们需要更精确的解决方案——自定义反序列化器。
让我们从零开始实现一个健壮的空字符串处理器。这个自定义反序列化器需要继承Jackson的JsonDeserializer基类,并指定泛型类型为List<String>:
java复制import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class EmptyStringListDeserializer extends JsonDeserializer<List<String>> {
@Override
public List<String> deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
JsonNode node = p.readValueAsTree();
// 处理显式的null值或缺失字段
if (node == null || node.isNull() || node.isMissingNode()) {
return new ArrayList<>();
}
// 处理空字符串情况
if (node.isTextual() && node.textValue().isEmpty()) {
return new ArrayList<>();
}
// 处理标准数组情况
if (node.isArray()) {
List<String> values = new ArrayList<>();
for (JsonNode element : node) {
if (element.isTextual()) {
values.add(element.textValue());
}
}
return values;
}
// 其他无法处理的类型返回空集合
return new ArrayList<>();
}
}
这个实现比基础版本更加健壮,它处理了更多边界情况:
有了反序列化器后,我们需要在实体类字段上通过注解来应用它。这里有个关键细节需要注意——注解的位置:
java复制public class ProjectVO {
// 方式1:直接注解在字段上(需要确保Lombok不会覆盖此注解)
@JsonDeserialize(using = EmptyStringListDeserializer.class)
private List<String> projectTypeId;
// 方式2:注解在setter方法上(更可靠)
@JsonDeserialize(using = EmptyStringListDeserializer.class)
public void setProjectTypeId(List<String> projectTypeId) {
this.projectTypeId = projectTypeId;
}
}
如果你使用Lombok的@Data或@Setter注解,方式1可能会失效,因为Lombok生成的setter方法默认不会保留字段上的注解。这就是为什么方式2更可靠——它明确指定了setter方法的行为。
目前的实现只处理List<String>,我们可以通过泛型让它支持更多集合类型:
java复制public class EmptyStringCollectionDeserializer<T extends Collection<String>>
extends JsonDeserializer<T> {
private final Supplier<T> collectionSupplier;
public EmptyStringCollectionDeserializer(Supplier<T> collectionSupplier) {
this.collectionSupplier = collectionSupplier;
}
@Override
public T deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
// ...相同的反序列化逻辑...
return collectionSupplier.get();
}
}
使用时可以通过构造函数传入集合工厂:
java复制@JsonDeserialize(using = new EmptyStringCollectionDeserializer<>(ArrayList::new))
private List<String> projectTypeId;
@JsonDeserialize(using = new EmptyStringCollectionDeserializer<>(HashSet::new))
private Set<String> tags;
如果项目中有大量需要这种处理的字段,逐个注解会很繁琐。我们可以通过Jackson的模块机制注册全局处理:
java复制@Configuration
public class JacksonConfig {
@Bean
public Module customDeserializersModule() {
SimpleModule module = new SimpleModule();
module.addDeserializer(List.class, new EmptyStringListDeserializer());
module.addDeserializer(Set.class, new EmptyStringSetDeserializer());
return module;
}
}
这样就不需要在每个字段上添加注解了。但要注意,全局注册会影响所有对应类型的处理,可能会与某些特殊场景冲突。
实现完反序列化器后,我们需要全面的测试来验证其行为。以下是一些关键的测试用例:
java复制public class EmptyStringListDeserializerTest {
private ObjectMapper mapper = new ObjectMapper();
@Test
public void testEmptyString() throws Exception {
String json = "{\"projectTypeId\":\"\"}";
ProjectVO vo = mapper.readValue(json, ProjectVO.class);
assertTrue(vo.getProjectTypeId().isEmpty());
}
@Test
public void testNullValue() throws Exception {
String json = "{\"projectTypeId\":null}";
ProjectVO vo = mapper.readValue(json, ProjectVO.class);
assertTrue(vo.getProjectTypeId().isEmpty());
}
@Test
public void testMissingField() throws Exception {
String json = "{}";
ProjectVO vo = mapper.readValue(json, ProjectVO.class);
assertNull(vo.getProjectTypeId()); // 取决于实体类初始化
}
@Test
public void testNormalArray() throws Exception {
String json = "{\"projectTypeId\":[\"type1\",\"type2\"]}";
ProjectVO vo = mapper.readValue(json, ProjectVO.class);
assertEquals(2, vo.getProjectTypeId().size());
}
@Test
public void testMixedArray() throws Exception {
String json = "{\"projectTypeId\":[\"type1\",123,true]}";
ProjectVO vo = mapper.readValue(json, ProjectVO.class);
assertEquals(1, vo.getProjectTypeId().size()); // 只包含文本元素
}
}
在实际项目中,我建议至少覆盖以下场景:
虽然自定义反序列化器是较为彻底的解决方案,但在某些简单场景下,也可以考虑其他替代方案:
java复制public class ProjectVO {
private List<String> projectTypeId;
@JsonSetter
public void setProjectTypeId(Object value) {
this.projectTypeId = value instanceof String && ((String)value).isEmpty()
? new ArrayList<>()
: (List<String>)value;
}
}
这种方法更轻量,但类型安全性较差,需要在setter方法中做类型判断和转换。
在application.properties中:
properties复制spring.jackson.coercion.accept-empty-string-as-empty-array=true
或者在配置类中:
java复制@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_EMPTY_STRING_AS_NULL_OBJECT)
.build();
}
}
全局配置的优点是简单,缺点是会影响所有空字符串到集合的转换,可能在某些场景下产生意外行为。
从长远来看,最好的解决方案是建立前后端交互规范:
[]表示null表示这需要团队达成共识并建立完善的接口文档,但在大型项目中,这种规范化的收益会远远超过初期成本。
在实现自定义反序列化器时,我们也需要考虑性能因素:
对象复用:对于频繁创建的小集合,可以考虑使用静态的空集合实例:
java复制private static final List<String> EMPTY_LIST = Collections.emptyList();
// 在deserialize方法中
if (node.isTextual() && node.textValue().isEmpty()) {
return EMPTY_LIST;
}
避免过度处理:在反序列化器中只处理必要的特殊情况,常规情况应该交给Jackson的默认处理流程。
缓存JsonNode:对于复杂的反序列化逻辑,可以先将JsonNode完全读入内存,避免多次解析。
线程安全:确保反序列化器是无状态的,可以安全地在多线程环境下共享。
在实际项目中,我建议在关键接口上对自定义反序列化器进行性能测试,确保不会成为系统瓶颈。特别是在高并发的微服务场景下,JSON处理的性能影响会被放大。