1. 개요

이 예제에서는 간단한 Spring Boot 애플리케이션 에서 JaVers를 설정하고 사용 하여 엔티티 변경을 추적 하는 방법을 살펴 봅니다 .

2. JaVers

변경 가능한 데이터를 다룰 때 일반적으로 데이터베이스에 저장된 엔티티의 마지막 상태 만 있습니다. 개발자로서 우리는 상태를 변경 한 이벤트에 대한 로그 파일을 검색하면서 애플리케이션을 디버깅하는 데 많은 시간을 보냅니다. 많은 다른 사용자가 시스템을 사용하는 프로덕션 환경에서는 더욱 까다로워집니다.

다행히 JaVers 와 같은 훌륭한 도구가 있습니다 . JaVers는 애플리케이션의 엔티티 변경을 추적하는 데 도움이되는 감사 로그 프레임 워크입니다.

이 도구의 사용은 디버깅 및 감사에만 국한되지 않습니다. 분석을 수행하고 Security 정책을 적용하고 이벤트 로그를 유지하는데도 성공적으로 적용될 수 있습니다.

3. 프로젝트 설정

우선 JaVers 사용을 시작하려면 엔티티의 스냅 샷을 유지하기위한 감사 저장소를 구성해야합니다. 둘째, JaVers의 구성 가능한 속성을 조정해야합니다. 마지막으로 도메인 모델을 올바르게 구성하는 방법도 다룹니다.

그러나 JaVers는 기본 구성 옵션을 제공하므로 거의 구성없이 사용할 수 있습니다.

3.1. 의존성

먼저 프로젝트에 JaVers Spring Boot 스타터 의존성을 추가해야합니다. 지속성 스토리지 유형에 따라 org.javers : javers-spring-boot-starter-sqlorg.javers : javers-spring-boot-starter- mongo 두 가지 옵션이 있습니다 . 이 사용방법(예제)에서는 Spring Boot SQL 스타터를 사용합니다.

<dependency>
    <groupId>org.javers</groupId>
    <artifactId>javers-spring-boot-starter-sql</artifactId>
    <version>5.14.0</version>
</dependency>

H2 데이터베이스를 사용할 것이므로이 의존성도 포함하겠습니다.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
</dependency>

3.2. JaVers 리포지토리 설정

JaVers는 커밋 및 직렬화 된 엔티티를 저장하기 위해 저장소 추상화를 사용합니다. 모든 데이터는 JSON 형식으로 저장 됩니다 . 따라서 NoSQL 저장소를 사용하는 것이 적합 할 수 있습니다. 그러나 단순성을 위해 H2 인 메모리 인스턴스를 사용합니다 .

기본적으로 JaVers는 메모리 내 저장소 구현을 활용하며 Spring Boot를 사용하는 경우 추가 구성이 필요하지 않습니다. 또한, 사용하는 동안 Spring 데이터 스타터, JaVers은 응용 프로그램의 데이터베이스 구성을 재사용합니다 .

JaVers는 SQL 및 Mongo 지속성 스택에 대한 두 가지 스타터를 제공합니다. SpringData와 호환되며 기본적으로 추가 구성이 필요하지 않습니다. 그러나 항상 기본 구성 Bean 인 JaversSqlAutoConfiguration.javaJaversMongoAutoConfiguration.java를 재정의 할 수 있습니다 .

3.3. JaVers 속성

JaVers는 대부분의 사용 사례에서 Spring Boot 기본값 으로 충분 하지만 여러 옵션을 구성 할 수 있습니다 .

새로 생성 된 객체의 스냅 샷을 얻을 수 있도록 newObjectSnapshot 하나만 재정의하겠습니다 .

javers.newObjectSnapshot=true

3.4. JaVers 도메인 구성

JaVers는 내부적으로 엔티티, 값 객체, 값, 컨테이너 및 프리미티브 유형을 정의합니다. 이러한 용어 중 일부는 DDD (Domain Driven Design) 용어 에서 비롯되었습니다 .

여러 유형을 갖는 주된 목적은 유형에 따라 다른 diff 알고리즘을 제공하는 것입니다 . 각 유형에는 해당하는 diff 전략이 있습니다. 결과적으로 애플리케이션 클래스가 잘못 구성되면 예측할 수없는 결과를 얻게됩니다.

JaVers에게 수업에 사용할 유형을 알리기 위해 몇 가지 옵션이 있습니다.

  • 명시 적으로 – 첫 번째 옵션은 JaversBuilder 클래스 register * 메서드 를 명시 적으로 사용 하는 것입니다. 두 번째 방법은 어노테이션을 사용하는 것입니다.
  • 암시 적으로 – JaVers는 클래스 관계를 기반으로 유형을 자동으로 감지하는 알고리즘을 제공합니다.
  • 기본값 – 기본적으로 JaVers는 모든 클래스를 ValueObjects 로 취급합니다.

이 예제에서는 어노테이션 방법을 사용하여 JaVers를 명시 적으로 구성합니다.

좋은 점은 JaVers가 javax.persistence 어노테이션 과 호환 된다는 것입니다 . 결과적으로 엔터티에 JaVers 관련 어노테이션을 사용할 필요가 없습니다.

4. 샘플 프로젝트

이제 감사 할 여러 도메인 엔터티를 포함하는 간단한 애플리케이션을 만들 것입니다.

4.1. 도메인 모델

우리 도메인에는 제품이있는 상점이 포함됩니다.

Store 엔터티를 정의 해 보겠습니다 .

@Entity
public class Store {

    @Id
    @GeneratedValue
    private int id;
    private String name;

    @Embedded
    private Address address;

    @OneToMany(
      mappedBy = "store",
      cascade = CascadeType.ALL,
      orphanRemoval = true
    )
    private List<Product> products = new ArrayList<>();
    
    // constructors, getters, setters
}

기본 JPA 어노테이션을 사용하고 있습니다. JaVers는 다음과 같은 방식으로 매핑합니다.

  • @ javax.persistence.Entity@ org.javers.core.metamodel.annotation.Entity에 매핑됩니다.
  • @ javax.persistence.Embeddable@ org.javers.core.metamodel.annotation.ValueObject에 매핑됩니다 .

포함 가능한 클래스는 일반적인 방식으로 정의됩니다.

@Embeddable
public class Address {
    private String address;
    private Integer zipCode;
}

4.2. 데이터 저장소

JPA 저장소를 감사하기 위해 JaVers는 @JaversSpringDataAuditable 어노테이션을 제공합니다 .

해당 어노테이션으로 StoreRepository정의 해 보겠습니다 .

@JaversSpringDataAuditable
public interface StoreRepository extends CrudRepository<Store, Integer> {
}

또한 ProductRepository 가 있지만 어노테이션은 없습니다.

public interface ProductRepository extends CrudRepository<Product, Integer> {
}

이제 SpringData 저장소를 사용하지 않는 경우를 고려하십시오. JaVers는 그 목적을위한 또 다른 메소드 레벨 어노테이션을 가지고 있습니다 : @JaversAuditable.

예를 들어 다음과 같이 제품을 유지하는 방법을 정의 할 수 있습니다.

@JaversAuditable
public void saveProduct(Product product) {
    // save object
}

또는 저장소 인터페이스의 메소드 바로 위에이 어노테이션을 추가 할 수도 있습니다.

public interface ProductRepository extends CrudRepository<Product, Integer> {
    @Override
    @JaversAuditable
    <S extends Product> S save(S s);
}

4.3. 저자 제공자

JaVers에서 커밋 된 각 변경에는 작성자가 있어야합니다. 또한 JaVers는 기본적으로 Spring Security를 지원합니다 .

결과적으로 각 커밋은 인증 된 특정 사용자가 수행합니다. 그러나이 사용방법(예제)에서는 AuthorProvider 인터페이스 의 매우 간단한 사용자 지정 구현을 만듭니다 .

private static class SimpleAuthorProvider implements AuthorProvider {
    @Override
    public String provide() {
        return "Baeldung Author";
    }
}

마지막 단계로 JaVers가 사용자 정의 구현을 사용하도록하려면 기본 구성 빈을 재정의해야합니다.

@Bean
public AuthorProvider provideJaversAuthor() {
    return new SimpleAuthorProvider();
}

5. JaVers 감사

마지막으로 애플리케이션을 감사 할 준비가되었습니다. 애플리케이션에 변경 사항을 전달하고 JaVers 커밋 로그를 검색하기 위해 간단한 컨트롤러를 사용합니다. 또는 H2 콘솔에 액세스하여 데이터베이스의 내부 구조를 볼 수도 있습니다.

 

 

 

 

 

 

 

초기 샘플 데이터 를 얻기 위해 EventListener사용하여 데이터베이스를 일부 제품으로 채 웁니다.

@EventListener
public void appReady(ApplicationReadyEvent event) {
    Store store = new Store("Baeldung store", new Address("Some street", 22222));
    for (int i = 1; i < 3; i++) {
        Product product = new Product("Product #" + i, 100 * i);
        store.addProduct(product);
    }
    storeRepository.save(store);
}

5.1. 초기 커밋

객체가 생성되면 JaVers는 먼저 INITIAL 유형 의 커밋을 만듭니다 .

애플리케이션 시작 후 스냅 샷을 확인해 보겠습니다.

@GetMapping("/stores/snapshots")
public String getStoresSnapshots() {
    QueryBuilder jqlQuery = QueryBuilder.byClass(Store.class);
    List<CdoSnapshot> snapshots = javers.findSnapshots(jqlQuery.build());
    return javers.getJsonConverter().toJson(snapshots);
}

위의 코드에서 우리는 Store 클래스에 대한 스냅 샷을 위해 JaVers를 쿼리하고 있습니다. 이 엔드 포인트에 요청하면 아래와 같은 결과를 얻을 수 있습니다.

[
  {
    "commitMetadata": {
      "author": "Baeldung Author",
      "properties": [],
      "commitDate": "2019-08-26T07:04:06.776",
      "commitDateInstant": "2019-08-26T04:04:06.776Z",
      "id": 1.00
    },
    "globalId": {
      "entity": "com.baeldung.springjavers.domain.Store",
      "cdoId": 1
    },
    "state": {
      "address": {
        "valueObject": "com.baeldung.springjavers.domain.Address",
        "ownerId": {
          "entity": "com.baeldung.springjavers.domain.Store",
          "cdoId": 1
        },
        "fragment": "address"
      },
      "name": "Baeldung store",
      "id": 1,
      "products": [
        {
          "entity": "com.baeldung.springjavers.domain.Product",
          "cdoId": 2
        },
        {
          "entity": "com.baeldung.springjavers.domain.Product",
          "cdoId": 3
        }
      ]
    },
    "changedProperties": [
      "address",
      "name",
      "id",
      "products"
    ],
    "type": "INITIAL",
    "version": 1
  }
]

위의 스냅 샷 에는 ProductRepository 인터페이스에 대한 어노테이션이 누락 되었음에도 불구하고 상점에 추가 된 모든 제품이 포함 됩니다 .

기본적으로 JaVers는 상위와 함께 유지되는 경우 집계 루트의 모든 관련 모델을 감사합니다.

DiffIgnore 어노테이션 을 사용하여 JaVers 에게 특정 클래스를 무시하도록 지시 할 수 있습니다 .

예를 들어, Store 엔터티 의 어노테이션으로 제품 필드에 어노테이션을 달 수 있습니다 .

@DiffIgnore
private List<Product> products = new ArrayList<>();

결과적으로 JaVers는 Store 엔터티 에서 생성 된 제품의 변경 사항을 추적하지 않습니다 .

5.2. 커밋 업데이트

다음 커밋 유형은 UPDATE 커밋입니다. 이것은 객체 상태의 변화를 나타내므로 가장 가치있는 커밋 유형입니다.

상점 엔터티와 상점의 모든 제품을 업데이트하는 메서드를 정의 해 보겠습니다.

public void rebrandStore(int storeId, String updatedName) {
    Optional<Store> storeOpt = storeRepository.findById(storeId);
    storeOpt.ifPresent(store -> {
        store.setName(updatedName);
        store.getProducts().forEach(product -> {
            product.setNamePrefix(updatedName);
        });
        storeRepository.save(store);
    });
}

이 메서드를 실행하면 디버그 출력에 다음 줄이 표시됩니다 (동일한 제품 및 매장 개수의 경우).

11:29:35.439 [http-nio-8080-exec-2] INFO  org.javers.core.Javers - Commit(id:2.0, snapshots:3, author:Baeldung Author, changes - ValueChange:3), done in 48 millis (diff:43, persist:5)

JaVers가 성공적으로 변경 사항을 유지 했으므로 제품에 대한 스냅 샷을 쿼리 해 보겠습니다.

@GetMapping("/products/snapshots")
public String getProductSnapshots() {
    QueryBuilder jqlQuery = QueryBuilder.byClass(Product.class);
    List<CdoSnapshot> snapshots = javers.findSnapshots(jqlQuery.build());
    return javers.getJsonConverter().toJson(snapshots);
}

이전 INITIAL 커밋과 새 UPDATE 커밋을 얻을 수 있습니다 .

 {
    "commitMetadata": {
      "author": "Baeldung Author",
      "properties": [],
      "commitDate": "2019-08-26T12:55:20.197",
      "commitDateInstant": "2019-08-26T09:55:20.197Z",
      "id": 2.00
    },
    "globalId": {
      "entity": "com.baeldung.springjavers.domain.Product",
      "cdoId": 3
    },
    "state": {
      "price": 200.0,
      "name": "NewProduct #2",
      "id": 3,
      "store": {
        "entity": "com.baeldung.springjavers.domain.Store",
        "cdoId": 1
      }
    }
}

여기에서 변경 사항에 대한 모든 정보를 볼 수 있습니다.

JaVers가 데이터베이스에 대한 새로운 연결을 생성하지 않는다는 점은 주목할 가치가 있습니다. 대신 기존 연결을 재사용합니다 . JaVers 데이터는 동일한 트랜잭션에서 애플리케이션 데이터와 함께 커밋되거나 롤백됩니다.

5.3. 변화

JaVers는 변경 사항을 객체 버전 간의 원자 적 차이로 기록합니다 . JaVers 체계에서 볼 수 있듯이 변경 사항을 저장하기위한 별도의 테이블이 없으므로 JaVers는 스냅 샷 간의 차이로 동적으로 변경 사항을 계산 합니다.

제품 가격을 업데이트 해 보겠습니다.

public void updateProductPrice(Integer productId, Double price) {
    Optional<Product> productOpt = productRepository.findById(productId);
    productOpt.ifPresent(product -> {
        product.setPrice(price);
        productRepository.save(product);
    });
}

그런 다음 JaVers에 변경 사항을 쿼리 해 보겠습니다.

@GetMapping("/products/{productId}/changes")
public String getProductChanges(@PathVariable int productId) {
    Product product = storeService.findProductById(productId);
    QueryBuilder jqlQuery = QueryBuilder.byInstance(product);
    Changes changes = javers.findChanges(jqlQuery.build());
    return javers.getJsonConverter().toJson(changes);
}

출력에는 변경된 속성과 그 전후의 값이 포함됩니다.

[
  {
    "changeType": "ValueChange",
    "globalId": {
      "entity": "com.baeldung.springjavers.domain.Product",
      "cdoId": 2
    },
    "commitMetadata": {
      "author": "Baeldung Author",
      "properties": [],
      "commitDate": "2019-08-26T16:22:33.339",
      "commitDateInstant": "2019-08-26T13:22:33.339Z",
      "id": 2.00
    },
    "property": "price",
    "propertyChangeType": "PROPERTY_VALUE_CHANGED",
    "left": 100.0,
    "right": 3333.0
  }
]

변경 유형을 감지하기 위해 JaVers는 객체 업데이트의 후속 스냅 샷을 비교합니다. 위의 경우 엔터티의 속성을 변경 했으므로 PROPERTY_VALUE_CHANGED 변경 유형이 있습니다.

5.4. 그림자

또한 JaVers는 Shadow 라고하는 감사 된 엔티티의 또 다른보기를 제공합니다 . Shadow는 스냅 샷에서 복원 된 개체 상태를 나타냅니다. 이 개념은 이벤트 소싱 과 밀접한 관련이 있습니다.

그림자에는 네 가지 범위가 있습니다.

  • Shallow — JQL 쿼리 내에서 선택한 스냅 샷에서 섀도우가 생성됩니다.
  • 자식 값 개체 — 그림자에는 선택한 개체가 소유 한 모든 자식 값 개체가 포함됩니다.
  • Commit-deep — 선택한 엔티티와 관련된 모든 스냅 샷에서 그림자가 생성됩니다.
  • Deep + — JaVers는 모든 개체가로드 된 상태에서 전체 개체 그래프를 복원하려고합니다.

Child-value-object 범위를 사용하고 단일 저장소에 대한 그림자를 가져옵니다.

@GetMapping("/stores/{storeId}/shadows")
public String getStoreShadows(@PathVariable int storeId) {
    Store store = storeService.findStoreById(storeId);
    JqlQuery jqlQuery = QueryBuilder.byInstance(store)
      .withChildValueObjects().build();
    List<Shadow<Store>> shadows = javers.findShadows(jqlQuery);
    return javers.getJsonConverter().toJson(shadows.get(0));
}

결과적으로 주소 값 개체 가있는 상점 엔터티를 가져옵니다 .

{
  "commitMetadata": {
    "author": "Baeldung Author",
    "properties": [],
    "commitDate": "2019-08-26T16:09:20.674",
    "commitDateInstant": "2019-08-26T13:09:20.674Z",
    "id": 1.00
  },
  "it": {
    "id": 1,
    "name": "Baeldung store",
    "address": {
      "address": "Some street",
      "zipCode": 22222
    },
    "products": []
  }
}

결과에서 제품을 얻기 위해 Commit-deep 범위를 적용 할 수 있습니다.

6. 결론

이 예제에서 우리는 JaVers가 특히 Spring Boot 및 Spring Data와 얼마나 쉽게 통합되는지 살펴 보았습니다. 대체로 JaVers는 설정하는 데 거의 제로 구성이 필요하지 않습니다.

결론적으로 JaVers는 디버깅에서 복잡한 분석에 이르기까지 다양한 응용 프로그램을 가질 수 있습니다.

이 기사의 전체 프로젝트는 GitHub에서 확인할 수 있습니다 .