使用spring-data-mongodb存储含Path类型字段的实体出现StackOverflowError
环境与基础代码
项目中使用spring-boot-starter-data-mongodb:2.5.3(对应spring-data-mongodb:3.2.3),简化实体类如下:
@Document public class Task { @Id private final String id; private final Path taskDir; // 构造方法、getter、setter }
配套默认Spring MongoDB repository可通过id查询任务,Mongo配置如下:
@Configuration @EnableMongoRepositories(basePackages = { "path.to.repository" }, mongoTemplateRef = MongoConfig.MONGO_TEMPLATE_REF) @EnableConfigurationProperties(MongoSettings.class) public class MongoConfig extends MongoConfigurationSupport { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); public static final String MONGO_TEMPLATE_REF = "mongoAlTemplate"; private final MongoSettings mongoSettings; @Autowired public MongoConfig(final MongoSettings mongoSettings) { this.mongoSettings = mongoSettings; } @Bean(name = "ourMongo", destroyMethod = "close") public MongoClient ourMongoClient() { MongoCredential credential = MongoCredential.createCredential(mongoSettings.getUser(), mongoSettings.getDb(), mongoSettings.getPassword()); MongoClientSettings clientSettings = MongoClientSettings.builder() .readPreference(ReadPreference.primary()) // 为@Version和eTag启用乐观锁 .writeConcern(WriteConcern.ACKNOWLEDGED) .credential(credential) .applyToSocketSettings( builder -> builder.connectTimeout(15, TimeUnit.SECONDS) .readTimeout(1, TimeUnit.MINUTES)) .applyToConnectionPoolSettings( builder -> builder.maxConnectionIdleTime(10, TimeUnit.MINUTES) .minSize(5).maxSize(20)) .build(); return MongoClients.create(clientSettings); } @Override @Nonnull protected String getDatabaseName() { return mongoSettings.getDb(); } @Bean(name = MONGO_TEMPLATE_REF) public MongoTemplate ourMongoTemplate() throws Exception { return new MongoTemplate(ourMongoClient(), getDatabaseName()); } }
报错信息
调用taskRepository.save(task)存储任务时抛出StackOverflowError,错误栈如下:
java.lang.StackOverflowError at java.lang.ThreadLocal.get(ThreadLocal.java:160) at java.util.concurrent.locks.ReentrantReadWriteLock$Sync.tryReleaseShared(ReentrantReadWriteLock.java:423) at java.util.concurrent.locks.AbstractQueuedSynchronizer.releaseShared(AbstractQueuedSynchronizer.java:1341) at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.unlock(ReentrantReadWriteLock.java:881) at org.springframework.data.mapping.context.AbstractMappingContext.getPersistentEntity(AbstractMappingContext.java:239) at org.springframework.data.mapping.context.AbstractMappingContext.getPersistentEntity(AbstractMappingContext.java:201) at org.springframework.data.mapping.context.AbstractMappingContext.getPersistentEntity(AbstractMappingContext.java:87) at org.springframework.data.mapping.context.MappingContext.getRequiredPersistentEntity(MappingContext.java:73) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writePropertyInternal(MappingMongoConverter.java:740) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writeProperties(MappingMongoConverter.java:657) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writeInternal(MappingMongoConverter.java:633) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writePropertyInternal(MappingMongoConverter.java:746) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writeProperties(MappingMongoConverter.java:657) at org.springframework.data.mongodb.core.convert.MappingMongoConverter.writeInternal(MappingMongoConverter.java:633) ...
给Task类的taskDir字段加@Transient注解即可正常存储,说明问题为Java/Spring/MongoDB无法直接处理Path类型对象。
排查过程
先后尝试在MongoConfig中配置Path与String互转的自定义转换器、配置Task直接转DBObject的转换器,仍报栈溢出,日志显示configureConverters方法已被调用、转换器已注册。
参照官方文档将转换器抽为独立类:
import org.bson.Document; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; import javax.annotation.Nonnull; @WritingConverter public class TaskWriteConverter implements Converter<Task, Document> { @Override public Document convert(@Nonnull Task source) { Document document = new Document(); document.put("_id", source.getId()); if (source.getTaskDir() != null) { document.put("taskDir", source.getTaskDir().normalize().toAbsolutePath().toString()); } return document; } }
MongoConfig配置调整为新增重写configureConverters方法:
@Override protected void configureConverters( MongoCustomConversions.MongoConverterConfigurationAdapter adapter) { LOG.info("configuring converters"); adapter.registerConverter(new TaskWriteConverter()); adapter.registerConverter(new TaskReadConverter()); adapter.registerConverter(new Converter<Path, String>() { @Override public String convert(@Nonnull Path path) { return path.normalize().toAbsolutePath().toString(); } }); adapter.registerConverter(new Converter<String, Path>() { @Override public Path convert(@Nonnull String path) { return Paths.get(path); } }); }
将org.springframework.data日志级别调为debug后,可看到自定义转换器已被识别:
2021-09-23 14:09:20.469 [INFO ] [ main] MongoConfig configuring converters 2021-09-23 14:09:20.480 [DEBUG] [ main] CustomConversions Adding user defined converter from class com.acme.Task to class org.bson.Document as writing converter. 2021-09-23 14:09:20.480 [DEBUG] [ main] CustomConversions Adding user defined converter from class org.bson.Document to class com.acme.Task as reading converter. 2021-09-23 14:09:20.481 [DEBUG] [ main] CustomConversions Adding user defined converter from interface java.nio.file.Path to class java.lang.String as writing converter. 2021-09-23 14:09:20.481 [DEBUG] [ main] CustomConversions Adding user defined converter from class java.lang.String to interface java.nio.file.Path as reading converter.
但转换器会被重复加载多次,自定义转换器仅在第三次加载时出现,最后一次加载日志中没有自定义转换器,疑似被覆盖。
复现问题的集成测试如下:
@SpringBootTest @AutoConfigureMockMvc @PropertySource("classpath:application-test.properties") public class SomeIT { @Autowired private TaskRepository taskRepository; @Test public void testTaskPersistence() throws Exception { Task task = new Task("1234", Paths.get("/home/roman")); taskRepository.save(task); } }
当前简单实体可正常存储到docker部署的MongoDB中,仅含Path字段的实体存储失败。
问题
为什么自定义转换器不生效?如何配置才能让Spring持久化时将Path转为String存储,读取时自动转回Path对象?
根本原因
你自定义的转换器未生效的核心问题是:你声明MongoTemplate时用了new MongoTemplate(ourMongoClient(), getDatabaseName())这个简化构造方法,该方法会初始化默认的MappingMongoConverter,不会加载你重写configureConverters方法注册的自定义转换器。你看到的转换器重复加载、最后一次加载无自定义转换器的现象,是Spring Boot自动配置的Mongo组件和你自定义配置的加载逻辑冲突,最终生效的是未携带自定义转换的默认转换器实例。
修复方法
不需要修改已注册的转换器逻辑,仅需调整MongoTemplate的声明逻辑,让它使用父类MongoConfigurationSupport生成的、已经注入了自定义转换器的MappingMongoConverter即可:
- 先在
MongoConfig中新增MongoDatabaseFactorybean声明:
@Bean public MongoDatabaseFactory mongoDbFactory() { return new SimpleMongoClientDatabaseFactory(ourMongoClient(), getDatabaseName()); }
- 修改
ourMongoTemplate的实现,传入父类生成的转换器:
@Bean(name = MONGO_TEMPLATE_REF) public MongoTemplate ourMongoTemplate() throws Exception { return new MongoTemplate(mongoDbFactory(), mappingMongoConverter()); }
修改完成后重启项目,你注册的Path与String互转的转换器就会正常生效,不需要额外编写整个Task实体的转换器,taskDir字段会自动完成序列化与反序列化,不会再抛出栈溢出错误。
内容的提问来源于stack exchange,提问作者Roman Vottner

