Статьи

Каскадное сохранение данных Spring MongoDB на объектах DBRef

Spring Data MongoDB по умолчанию не поддерживает каскадные операции над объектами, на которые есть ссылки, с аннотациями @DBRef, как указано в справке :

Структура отображения не обрабатывает каскадные сохранения . Если вы изменяете объект Account, на который ссылается объект Person, вы должны сохранить объект Account отдельно . Вызов save для объекта Person не приведет к автоматическому сохранению объектов Account в учетных записях свойств.

Это довольно проблематично, потому что для достижения сохранения дочерних объектов вам необходимо переопределить метод save в репозитории в parent или создать дополнительные «сервисные» методы, подобные представленным здесь .

В этой статье я покажу вам, как этого можно добиться для всех документов, используя универсальную реализацию AbstractMongoEventListener .

@CascadeSave аннотация

Поскольку мы не можем изменить аннотацию @DBRef , добавив свойство cascade, давайте создадим новую аннотацию @CascadeSave, которая будет использоваться для обозначения полей, которые должны быть сохранены при сохранении родительского объекта.

01
02
03
04
05
06
07
08
09
10
11
12
package pl.maciejwalkowiak.springdata.mongodb;
 
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface CascadeSave {
 
}

CascadingMongoEventListener

Следующая часть — реализовать обработчик для этой аннотации. Мы будем использовать этот мощный механизм Spring Application Event . В частности, мы расширим AbstractMongoEventListener для перехвата сохраненного объекта перед его преобразованием в DBObject Монго .

Как это работает? Когда вызывается метод #save объекта MongoTemplate , перед сохранением объекта он преобразуется в DBObject из API MongoDB. Реализованный ниже CascadingMongoEventListener предоставляет хук, который ловит объект перед его преобразованием и:

  • Обходит все свои поля, чтобы проверить, есть ли поля, аннотированные @DBRef и @CascadeSave одновременно.
  • когда поле найдено, оно проверяет, присутствует ли аннотация @Id
  • дочерний объект сохраняется
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package pl.maciejwalkowiak.springdata.mongodb;
 
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.annotation.Id;
import org.springframework.data.mapping.model.MappingException;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
 
import java.lang.reflect.Field;
 
public class CascadingMongoEventListener extends AbstractMongoEventListener {
  @Autowired
  private MongoOperations mongoOperations;
 
  @Override
  public void onBeforeConvert(final Object source) {
      ReflectionUtils.doWithFields(source.getClass(), new ReflectionUtils.FieldCallback() {
 
          public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
              ReflectionUtils.makeAccessible(field);
 
              if (field.isAnnotationPresent(DBRef.class) && field.isAnnotationPresent(CascadeSave.class)) {
                  final Object fieldValue = field.get(source);
 
                  DbRefFieldCallback callback = new DbRefFieldCallback();
 
                  ReflectionUtils.doWithFields(fieldValue.getClass(), callback);
 
                  if (!callback.isIdFound()) {
                      throw new MappingException("Cannot perform cascade save on child object without id set");
                  }
 
                  mongoOperations.save(fieldValue);
              }
          }
      });
  }
 
  private static class DbRefFieldCallback implements ReflectionUtils.FieldCallback {
      private boolean idFound;
 
      public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
          ReflectionUtils.makeAccessible(field);
 
          if (field.isAnnotationPresent(Id.class)) {
              idFound = true;
          }
      }
 
      public boolean isIdFound() {
          return idFound;
      }
  }
}

Требования к картографии

Как вы можете видеть, чтобы заставить вещи работать, вы должны следовать некоторым правилам:

  • родительское дочернее свойство класса должно отображаться с помощью @DBRef и @CascadeSave
  • Дочерний класс должен иметь свойство, аннотированное @Id, и если этот идентификатор должен быть автоматически сгенерирован, он должен иметь тип ObjectId

использование

Чтобы использовать каскадное сохранение в вашем проекте, вам нужно просто зарегистрировать CascadingMongoEventListener в Spring Context:

1
<bean class="pl.maciejwalkowiak.springdata.mongodb.CascadingMongoEventListener" />

Давайте проверим это

Чтобы показать пример, я сделал два класса документов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Document
public class User {
  @Id
  private ObjectId id;
  private String name;
 
  @DBRef
  @CascadeSave
  private Address address;
 
  public User(String name) {
      this.name = name;
  }
 
  // ... getters, setters, equals hashcode
}
01
02
03
04
05
06
07
08
09
10
11
12
@Document
public class Address {
  @Id
  private ObjectId id;
  private String city;
 
  public Address(String city) {
      this.city = city;
  }
 
  // ... getters, setters, equals hashcode
}

В тесте есть один пользователь с созданным адресом, а затем пользователь сохраняется. Тест будет охватывать только положительный сценарий, и он просто показывает, что он действительно работает ( applcationContext-tests.xml содержит только applcationContext-tests.xml Spring Data MongoDB по умолчанию и зарегистрирован CascadingMongoEventListener):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"classpath:applcationContext-tests.xml"})
public class CascadingMongoEventListenerTest {
 
  @Autowired
  private MongoOperations mongoOperations;
 
  /**
  * Clean collections before tests are executed
  */
  @Before
  public void cleanCollections() {
      mongoOperations.dropCollection(User.class);
      mongoOperations.dropCollection(Address.class);
  }
 
  @Test
  public void testCascadeSave() {
      // given
      User user = new User("John Smith");
      user.setAddress(new Address("London"));
 
      // when
      mongoOperations.save(user);
 
      // then
      List<User> users = mongoOperations.findAll(User.class);
      assertThat(users).hasSize(1).containsOnly(user);
 
      User savedUser = users.get(0);
      assertThat(savedUser.getAddress()).isNotNull().isEqualTo(user.getAddress());
 
      List<Address> addresses = mongoOperations.findAll(Address.class);
      assertThat(addresses).hasSize(1).containsOnly(user.getAddress());
  }
}

Мы можем проверить это также в консоли Mongo:

1
2
3
4
> db.user.find()
{ "_id" : ObjectId("4f9d1bab1a8854250a5bf13e"), "_class" : "pl.maciejwalkowiak.springdata.mongodb.domain.User", "name" : "John Smith", "address" : { "$ref" : "address", "$id" : ObjectId("4f9d1ba41a8854250a5bf13d") } }
> db.address.find()
{ "_id" : ObjectId("4f9d1ba41a8854250a5bf13d"), "_class" : "pl.maciejwalkowiak.springdata.mongodb.domain.Address", "city" : "London" }

Резюме

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

Я полагаю, что мы найдем эту функциональность вместе с каскадным удалением как часть релиза Spring Data MongoDB в будущем. Представленное здесь решение работает, но:

  • требуется дополнительная аннотация
  • использует API отражения для итерации по полям, что не является самым быстрым способом сделать это (но не стесняйтесь реализовывать кэширование при необходимости)

Если это может быть частью Spring Data MongoDB вместо дополнительной аннотации, @DBRef может иметь дополнительный cascade свойств. Вместо отражения мы можем использовать MongoMappingContext вместе с MongoPersistentEntity . Я уже начал готовить пул-запрос с этими изменениями. Посмотрим, будет ли он принят командой Spring Source.