Spring 数据MongoDB:如何避免@CreatedBy 和@CreatedDate 字段更新?

Spring Data MongoDB: how to avoid @CreatedBy and @CreatedDate fields update?

我正在使用 Spring-Boot 2.5.0 和 MongoDB 来保存一些文档。 这里是 Github Project.

对于每个文档,我还需要自动保存一些审计信息,因此我扩展了以下 class:

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.springframework.data.annotation.*;
import java.time.Instant;

@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public abstract class AuditingDocument {
    @Version
    private Long version;

    @CreatedBy
    private String creator;
    @CreatedDate
    private Instant created;

    @LastModifiedBy
    private String modifier;
    @LastModifiedDate
    private Instant modified;
}

例如让我们考虑 Book class:

@Data
@SuperBuilder
@Document
@NoArgsConstructor
@AllArgsConstructor
public class Book extends AuditingDocument {
    @Id
    private String id;

    private String name;
}

我遇到的问题是,当我通过 JSON/REST API 更新文档时, 我能够 alter/overwrite @CreatedBy@CreatedDate 字段的值。

意味着如果没有提供字段那么结果值将被保存为空值,否则,它将为创建者和创建的字段保存新值。

这不应该被允许,因为它代表了大多数用例中的安全问题。 如何使这两个字段不可更新?如果创建者存在,则以后无需更新。此类值会自动填充,因此不会出现需要更新值的错误。

我发现了其他类似的问题,但它们是关于 JPA 而不是 MongoDB,例如

他们在这里使用

@Column(name = "created_by", updatable = false)

保护字段不被更新。 不幸的是,MongoDB 的 @Field 没有这样的 属性。

我怎样才能保护所有这些字段在它们已经存在于数据库中之后不被修改?显然,我需要一个能够与所有 @Document 实体一起扩展而无需单独处理每个实体的解决方案,例如通过从数据库中手动读取并修复要先保存的文档。

更新

我试图通过重写 MongoTemplate 子 class.

中的 doUpdate 方法来实现此行为
public class CustomMongoTemplate extends MongoTemplate {
    public CustomMongoTemplate(MongoClient mongoClient, String databaseName) {
        super(mongoClient, databaseName);
    }

    public CustomMongoTemplate(MongoDatabaseFactory mongoDbFactory) {
        super(mongoDbFactory);
    }

    public CustomMongoTemplate(MongoDatabaseFactory mongoDbFactory, MongoConverter mongoConverter) {
        super(mongoDbFactory, mongoConverter);
    }

    @Override
    protected UpdateResult doUpdate(String collectionName, Query query, UpdateDefinition update, Class<?> entityClass, boolean upsert, boolean multi) {
        Document updateDocument = update.getUpdateObject();
        List<?> list = this.find(query, entityClass);

        if (!list.isEmpty()) {
            Object existingObject = list.get(0);
            Document existingDocument = new Document();
            this.getConverter().write(existingObject, existingDocument);

            // Keep the values of the existing document
            if (existingDocument.keySet().containsAll(Arrays.asList("version", "creator", "created"))) {
//                Long version = existingDocument.getLong("version");
                String creator = existingDocument.getString("creator");
                Date created = existingDocument.getDate("created");

                System.out.println("Creator: " + creator);
                System.out.println("Created: " + created);

//                updateDocument.put("version", version++);
                updateDocument.put("creator", creator);
                updateDocument.put("created", created);

                System.out.println("Update Document");
                System.out.println(updateDocument.toJson());
            }

            return super.doUpdate(collectionName, query, Update.fromDocument(updateDocument), entityClass, upsert, multi);
        } else {
            return super.doUpdate(collectionName, query, update, entityClass, upsert, multi);
        }
    }
}

这种方法部分有效,这意味着在我调用存储库的保存方法后,更新的对象不会覆盖现有的创建者和创建的字段,但是由于某种原因,保存方法 returns创建者和创建者具有空值的对象,即使在数据库中文档具有这样的值。

我还尝试一次获取集合中的所有文档,并且它们的值(创建者、创建者)已正确填充并由 API 端点 return 编辑。似乎 doUpdate() 方法出了问题,但我无法理解。

更新 2

每个文档都使用实现此接口的服务保存在数据库中,它只需调用 MongoRepository 的相应 save() 方法。

import org.apache.commons.collections4.IterableUtils;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
import java.util.Optional;

public interface EntityService<T, K> {
    MongoRepository<T, K> getRepository();

    default Optional<T> findById(K id) {
        return this.getRepository().findById(id);
    }

    default List<T> findAll(){
        return this.getRepository().findAll();
    }

    default List<T> findAllByIds(List<K> ids){
        return IterableUtils.toList(this.getRepository().findAllById(ids));
    }

    default T save(T entity) {
        return this.getRepository().save(entity);
    }

    default List<T> save(Iterable<T> entities) {
        return this.getRepository().saveAll(entities);
    }

    default void delete(T entity) {
        this.getRepository().delete(entity);
    }

    default void delete(Iterable<T> entity) {
        this.getRepository().deleteAll(entity);
    }
}

这是对应的@Repository

import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface BookRepository extends MongoRepository<Book, String>, QuerydslPredicateExecutor<Book> {}

更新 3

RestController调用这个方法,这里的服务就是上面定义的:

default T save(T entity) {
    return this.convert(this.getService().save(this.decode(entity)));
}

这些是转换和解码方法:

    @Override
    public BookDTO convert(Book source) {
        return BookDTO.builder()
                .id(source.getId())
                // Auditing Info
                .version(source.getVersion())
                .creator(source.getCreator())
                .created(source.getCreated())
                .modifier(source.getModifier())
                .modified(source.getModified())
                .build();
    }

    @Override
    public Book decode(BookDTO target) {
        return Book.builder()
                .id(target.getId())
                // Auditing Info
                .version(target.getVersion())
//                .creator(target.getCreator())
//                .created(target.getCreated())
//                .modifier(target.getModifier())
//                .modified(target.getModified())
                .build();
    }

更新 4

我刚刚创建了一个 Spring Boot/Java 16 MWP 来重现 GitHub 上的错误。

这是 RestController:

@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookRepository bookRepository;

    @PostMapping(value = "/book")
    public Book save(@RequestBody Book entity) {
        return this.bookRepository.save(entity);
    }

    @GetMapping(value = "/book/test")
    public Book test() {
        Book book = Book.builder().name("Book1").build();
        return this.bookRepository.save(book);
    }

    @GetMapping(value = "/books")
    public List<Book> books() {
        return this.bookRepository.findAll();
    }
}

如果我通过 "/book" 端点更新文档,数据库中的文档将被正确保存(使用现有的创建者和创建的字段),但它 returned 具有空值这些字段由 Rest Controller 提供。

但是,"/books" return 所有字段均正确填充的书籍。

似乎 the doUpdate 方法和控制器 return 之间有某种东西将这些字段设置为空。

更新 5

我创建了一些测试以更好地检查 BookRepository 的保存方法。

我发现了什么:

  1. 保存方法第一次正确地创建了这本书,所有审计字段(version, creator, created, modifier, modified)都按预期填充。
  2. 保存方法正确地更新了数据库中的现有书籍,为后续查找查询保留 creatorcreated 字段的现有值。
  3. 保存方法return创建者和创建字段设置为空的对象(但在数据库中,文档已填充所有审计字段)。

这是我的测试方法(也可以在 GitHub 上找到)。

import com.example.demo.domain.Book;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Rollback
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class BookRepositoryTests {
    @Autowired
    private BookRepository bookRepository;

    @Test
    @Order(1)
    @Transactional
    public void testCreateBook() {
        this.doCreateBook("1001", "Java Programming");
    }

    @Test
    @Order(2)
    @Transactional
    public void testUpdateBookAndFind() {
        this.doCreateBook("1002", "Python Programming");
        Book existingBook = this.bookRepository.findById("1002").orElse(null);

        // Check Existing Book
        Assertions.assertNotNull(existingBook);

        // Update
        existingBook.setCreated(null);
        existingBook.setCreator(null);
        existingBook.setModifier(null);
        existingBook.setModified(null);

        this.bookRepository.save(existingBook);
        Book existingUpdatedBook = this.bookRepository.findById("1002").orElse(null);

        // Check Existing Updated Book (Working)
        Assertions.assertNotNull(existingUpdatedBook);

        Assertions.assertNotNull(existingUpdatedBook.getCreator());
        Assertions.assertNotNull(existingUpdatedBook.getCreated());

        Assertions.assertNotNull(existingUpdatedBook.getModifier());
        Assertions.assertNotNull(existingUpdatedBook.getModified());
    }

    @Test
    @Order(3)
    @Transactional
    public void testUpdateBookDirect() {
        this.doCreateBook("1003", "Go Programming");
        Book existingBook = this.bookRepository.findById("1003").orElse(null);

        // Check Existing Book
        Assertions.assertNotNull(existingBook);

        // Update
        existingBook.setCreated(null);
        existingBook.setCreator(null);
        existingBook.setModifier(null);
        existingBook.setModified(null);

        Book updatedBook = this.bookRepository.save(existingBook);

        // Check Updated Book (Not working)
        Assertions.assertNotNull(updatedBook);

        Assertions.assertNotNull(updatedBook.getCreator());
        Assertions.assertNotNull(updatedBook.getCreated());

        Assertions.assertNotNull(updatedBook.getModifier());
        Assertions.assertNotNull(updatedBook.getModified());
    }

    private void doCreateBook(String bookID, String bookName) {
        // Create Book
        Book book = Book.builder().id(bookID).name(bookName).build();
        Book createdBook = this.bookRepository.save(book);

        Assertions.assertNotNull(createdBook);
        Assertions.assertEquals(bookID, createdBook.getId());
        Assertions.assertEquals(bookName, createdBook.getName());

        // Check Auditing Fields
        Assertions.assertNotNull(createdBook.getVersion());

        Assertions.assertNotNull(createdBook.getCreator());
        Assertions.assertNotNull(createdBook.getCreated());

        Assertions.assertNotNull(createdBook.getModifier());
        Assertions.assertNotNull(createdBook.getModified());
    }
}

在综合中,只有 testUpdateBookDirect() 方法的断言不起作用。 CustomMongoTemplate.doUpdate() 方法之后似乎有某种拦截器覆盖了这些字段(创建者,已创建)。

如果您不希望请求覆盖您的审计字段(或与此相关的任何其他字段),那么一种方法是对您的数据模型和 DTO 使用不同的 类,然后从在进出的路上一个接一个(Lombok 建设者让这变得超级简单)。

虽然转换有开销,而且需要维护这两个 类,但它确实将您的数据模型与端点的 public 面临的要求隔离开来。

例如Java 使用 SNAKE_CASE 作为枚举,但出于某些疯狂的原因,您需要在 API 上使用 kebab-case

或者您有一个多租户服务,您必须将租户保留在数据库中,但不需要或不想通过 DTO 公开它。

一个可能的解决方案或解决方法是:

  1. 延长 MongoTemplate class
  2. 覆盖 doUpdate 方法
  3. 结合存储库 savefind 方法来更新和 return 已更新的对象与正确填充的审核字段。这是必需的,因为出于某种原因,repository.save 方法 return 对于审计字段(creatorcreated)为 null,即使它们随后被正确填充到数据库中也是如此。

这里我们需要重写MongoTemplatedoUpdate方法。

@Override
protected UpdateResult doUpdate(String collectionName, Query query, UpdateDefinition update, Class<?> entityClass, boolean upsert, boolean multi) {
    Document updateDocument = update.getUpdateObject();
    List<?> list = this.find(query, entityClass);

    if (!list.isEmpty()) {
        Object existingObject = list.get(0);
        Document existingDocument = new Document();
        this.getConverter().write(existingObject, existingDocument);

        // Keep the values of the existing document
        if (existingDocument.keySet().containsAll(Arrays.asList("version", "creator", "created"))) {
            String creator = existingDocument.getString("creator");
            Date created = existingDocument.getDate("created");

            System.out.println("Creator: " + creator);
            System.out.println("Created: " + created);

            updateDocument.put("creator", creator);
            updateDocument.put("created", created);

            System.out.println("Update Document");
            System.out.println(updateDocument.toJson());
        }

        return super.doUpdate(collectionName, query, Update.fromDocument(updateDocument), entityClass, upsert, multi);
    } else {
        return super.doUpdate(collectionName, query, update, entityClass, upsert, multi);
    }
}

最后我使用调用存储库的服务来进行保存和查找操作。这是它实现的接口。

public interface EntityService<T extends MongoDBDocument<K>, K> {
    MongoRepository<T, K> getRepository();

    default T save(T entity) {
        // First save it
        this.getRepository().save(entity);

        // Then find it by ID
        return this.getRepository().findById(entity.getId()).orElse(entity);
    }

    default List<T> save(Iterable<T> entities) {
        // First save them
        List<T> savedEntities = this.getRepository().saveAll(entities);
        List<K> savedEntitiesIDs = savedEntities.stream().map(entity -> entity.getId()).collect(Collectors.toList());

        // Then find them by IDs
        return IterableUtils.toList(this.getRepository().findAllById(savedEntitiesIDs));
    }
}

通过这种方式,我可以做我正在寻找的事情:

  1. 让审计字段(version,creator,created,modifier,modified)由BE自动生成
  2. Return 这些字段通过 API 和 DTO。
  3. saveupdate API 端点使用相同的 DTO 接口。
  4. 忽略通过 APIs 来自外部的审计值。
  5. 切勿将审计字段保存为数据库中的空值或错误值。
  6. Return 更新 API 调用后正确和更新的审计字段。