Сохраняемые объекты пути с репозиториями spring-data-mongodb

#java #spring-data-mongodb

Вопрос:

В проекте, который я использую 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;
  ...

  // constructor, getters, setters
}
 

с репозиторием Spring MongoDB по умолчанию, который позволяет извлекать задачу по ее идентификатору.

Конфигурация 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())
        // enable optimistic locking for @Version and eTag usage
        .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))
//        .applyToClusterSettings(
//            builder -> builder.requiredClusterType(ClusterType.REPLICA_SET)
//                .hosts(Arrays.asList(new ServerAddress("host1", 27017),
//                                     new ServerAddress("host2", 27017)))
//                .build())
        .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) Java возникает ошибка StackOverflow

 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)
    ...
 

On annotating the path object taskDir in the Task class with @Transient I’m able to persist the task, so the problem seems to be related with Java/Spring/MongoDB being unable to handle Path objects directly.

My next attempt was to configure a custom converter inside the MongoConfig class to convert between Path and String representations:

 @Override
protected void configureConverters(
  MongoCustomConversions.MongoConverterConfigurationAdapter converterConfigurationAdapter) {
  LOG.info("configuring converters");
  converterConfigurationAdapter.registerConverter(new Converter<Path, String>() {
    @Override
    public String convert(@Nonnull Path path) {
      return path.normalize().toAbsolutePath().toString();
    }
  });
  converterConfigurationAdapter.registerConverter(new Converter<String, Path>() {
    @Override
    public Path convert(@Nonnull String path) {
      return Paths.get(path);
    }
  });
}
 

though the error remained. I then defined a direct conversion between the Task object and a DBObject as showcased in this guide

 @Override
protected void configureConverters(
  MongoCustomConversions.MongoConverterConfigurationAdapter converterConfigurationAdapter) {
  LOG.info("configuring converters");
  converterConfigurationAdapter.registerConverter(new Converter<Task, DBObject>() {
    @Override
    public DBObject convert(@Nonnull Task source) {
    DBObject dbObject = new BasicDBObject();
      if (source.getTaskDirectory() != null) {
        dbObject.put("taskDir", source.getTaskDirectory().normalize().toAbsolutePath().toString());
      }
      ...
      return dbObject;
    }
  });
}
 

and I still get a StackOverflowError in return. Through the log statement I added I see that Spring called into the configureConverters method and therefore should have registered the custom converters.

Why do I still get the StackOverflowError though? How do I need to tell Spring to treat Path objects as String s while persisting and on read-time convert the String value to a Path object back again?


Update:

I’ve now followed the example given in the official documentation and refactored the converter to its own class

 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;
  }
}
 

The configuration in the MongoConfig class now looks like this:

   @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 to 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.
 

Однако я вижу, что большинство конвертеров добавляются несколько раз, т. Е. Я нахожу журнал Adding converter from class java.lang.Character to class java.lang.String as writing converter. фактически 4 раза, прежде чем приложение попадет в save метод в репозитории. Поскольку мои пользовательские конвертеры добавляются только в 3-й раз, когда все эти конвертеры появляются в журналах, у меня такое чувство, что они каким-то образом перезаписаны, поскольку журналы в последней «итерации» не включают мои пользовательские конвертеры.

Тестовый пример, который воспроизводит эту проблему, выглядит следующим образом:

 @ŚpringBootTest
@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);
  }

   ...
}
 

Метод тестирования используется только для изучения текущей проблемы сохранения и при нормальных условиях вообще не должен присутствовать, поскольку интеграционный тест проверяет загрузку большого файла, его предварительную обработку и так далее. Однако эти интеграционные тесты завершаются неудачей из-за того, что Spring не может, по крайней мере, так кажется, хранить сущности, содержащие объекты Path.

Обратите внимание, что для простых сущностей у меня нет проблем с сохранением их с описанной настройкой, и я также вижу их в закрепленном MongoDB.

У меня еще не было времени углубиться в то, почему у Spring такие проблемы с Path объектами или почему мои пользовательские преобразователи внезапно исчезают на последней итерации вывода CustomConversions журнала.

Ответ №1:

Оказывается, что способ mongoTemplate настройки «перезаписывает» любые указанные пользовательские конвертеры, и, следовательно, Spring не может использовать их и конвертировать Path в String и наоборот.

После изменения MongoConfig на приведенное ниже, я, наконец, могу использовать свои пользовательские конвертеры и, таким образом, сохранять сущности, как и ожидалось:

 @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())
        // enable optimistic locking for @Version and eTag usage
        .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))
//        .applyToClusterSettings(
//            builder -> builder.requiredClusterType(ClusterType.REPLICA_SET)
//                .hosts(Arrays.asList(new ServerAddress("host1", 27017),
//                                     new ServerAddress("host2", 27017)))
//                .build())
        .build();
    LOG.info("Mongo client initialized. Connecting with user {} to DB {}",
             mongoSettings.getUser(), mongoSettings.getDb());
    return MongoClients.create(clientSettings);
  }

  @Override
  @Nonnull
  protected String getDatabaseName() {
    return mongoSettings.getDb();
  }

  @Bean
  public MongoDatabaseFactory ourMongoDBFactory() {
    return new SimpleMongoClientDatabaseFactory(ourMongoClient(), getDatabaseName());
  }

  @Bean(name = MONGO_TEMPLATE_REF)
  public MongoTemplate ourMongoTemplate() throws Exception {
    return new MongoTemplate(ourMongoDBFactory(), mappingMongoConverter());
  }

  @Bean
  public MappingMongoConverter mappingMongoConverter() throws Exception {
    DbRefResolver dbRefResolver = new DefaultDbRefResolver(ourMongoDBFactory());
    MongoCustomConversions customConversions = customConversions();
    MongoMappingContext context = mongoMappingContext(customConversions);
    MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, context);
    // this one is actually needed otherwise the StackOverflowError re-appears!
    converter.setCustomConversions(customConversions);
    return converter;
  }

  @Bean
  @Override
  @Nonnull
  public MongoCustomConversions customConversions() {
    return  new MongoCustomConversions(
        Arrays.asList(new PathWriteConverter(), new PathReadConverter())
    );
  }
}
 

Таким образом, вместо прямой передачи имени базы данных MongoClient и имени базы mongoTemplate MongoDatabaseFactory данных в шаблон в качестве входных данных передаются объект, содержащий вышеупомянутые значения, и MappingMongoConverter объект.

К сожалению, customConversion в рамках метода необходимо дважды передать объект mappingMongoConverter() . Если этого не сделать, StackOverflowError то он появится снова.

При данной конфигурации теперь возможны преобразования из Path в String и String в Path , и, следовательно, в настоящее время не требуется никаких пользовательских преобразований из Task в Document и наоборот.