Skip to content

Using Projections in Spring Data REST as DTO with cache to reduce the impact of 1+N queries problem

Notifications You must be signed in to change notification settings

Cepr0/projection-cache-demo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Projection with cache Demo

Using Projections in Spring Data REST as DTO with cache to reduce the impact of 1+N queries problem.

Our model:

 public class Book extends LongId {
 
     @NotBlank
     private final String title;
     
     @NotBlank
     @Column(unique = true)
     private final String isbn;
 
     @ManyToOne(optional = false)
     private final Author author;
 
     @ManyToOne(optional = false)
     private final Publisher publisher;
 }
 
 public class Author extends LongId {
 
     @NotBlank
     private final String name;
     
     @OneToMany(mappedBy = "author")
     private final List<Book> books = new ArrayList<>();
 }
 
 public class Publisher extends LongId {
 
     @NotBlank
     private final String name;
 
     @OneToMany(mappedBy = "publisher")
     private final List<Book> books = new ArrayList<>();
 }
 
 public class Reader extends LongId {
     
     @NotBlank
     private final String name;
     
     @NotBlank
     @Email
     @Column(unique = true)
     private final String email;
     
     @ManyToMany
     @JoinTable(name = "readings", joinColumns = @JoinColumn(name = "reader_id"), inverseJoinColumns = @JoinColumn(name = "book_id"))
     private final List<Book> books = new ArrayList<>();
 }
 
 public class Reading extends LongId {
 
     @NaturalId
     @ManyToOne(optional = false)
     private final Reader reader;
 
     @NaturalId
     @ManyToOne(optional = false)
     private final Book book;
     
     @NotBlank
     private String review;
     
     @Min(1) @Max(5)
     private Integer rating;
 }

The last entity Reading is used to store information about reading the Book by the Reader with some rating (from 1 to 5).

Suppose we need to get a book list with average rating and the number of reading of each book. To do so we can create a Projection like this:

public class Book extends LongId {

    //...
    
    @Projection(name = "bookRating", types = Book.class)
    public interface Ratings {
        
        String getTitle();
        String getIsbn();
        
        @Value("#{target.author.name}")
        String getAuthor();

        @Value("#{target.publisher.name}")
        String getPublisher();
    
        /**
         * {@link ReadingRepo#getBookRatings} is used to calculate {@link Book} ratings and cache result
         */
        @Value("#{@readingRepo.getBookRatings(target)}")
        Reading.Ratings getRatings();
    }
    
    //...
}

And the method getBookRatings in the ReadingRepo repository (for other entities we also make repositories):

@RepositoryRestResource
public interface ReadingRepo extends JpaRepository<Reading, Long> {

    @RestResource(exported = false)
    @Query("select avg(r.rating) as rating, count(r) as readings from Reading r where r.book = ?1")
    Reading.Ratings getBookRatings(Book book);
}

that return average rating and reading count of book:

public class Reading extends LongId {

    //...
    
    @JsonSerialize(as = Reading.Ratings.class)
    public interface Ratings {

        @JsonProperty("rating")
        Float getRating();

        @JsonProperty("readings")
        Integer getReadings();
    }
    
    //...
}

Now we can get a book list with ratings:

GET http://localhost:8080/api/books?projection=bookRating
{
  "_embedded" : {
    "books" : [ {
      "author" : "Author00003",
      "ratings" : {
        "rating" : 3.6666667,
        "readings" : 3
      },
      "publisher" : "Publisher00005",
      "isbn" : "00001",
      "title" : "Book00001",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/books/1"
        },
        "book" : {
          "href" : "http://localhost:8080/api/books/1{?projection}",
          "templated" : true,
          "title" : "Book"
        },
        "publisher" : {
          "href" : "http://localhost:8080/api/books/1/publisher",
          "title" : "Publisher"
        },
        "author" : {
          "href" : "http://localhost:8080/api/books/1/author",
          "title" : "Author"
        }
      }
    }, {
      "author" : "Author00001",
      "ratings" : {
        "rating" : 1.5,
        "readings" : 2
      },
      "publisher" : "Publisher00001",
      "isbn" : "00002",
      "title" : "Book00002",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/books/2"
        },
        "book" : {
          "href" : "http://localhost:8080/api/books/2{?projection}",
          "templated" : true,
          "title" : "Book"
        },
        "publisher" : {
          "href" : "http://localhost:8080/api/books/2/publisher",
          "title" : "Publisher"
        },
        "author" : {
          "href" : "http://localhost:8080/api/books/2/author",
          "title" : "Author"
        }
      }
    }
    //...
    ]
  },  
  "_links" : {
    "first" : {
      "href" : "http://localhost:8080/api/books?page=0&size=20",
      "title" : "First"
    },
    "self" : {
      "href" : "http://localhost:8080/api/books"
    },
    "next" : {
      "href" : "http://localhost:8080/api/books?page=1&size=20",
      "title" : "Next"
    },
    "last" : {
      "href" : "http://localhost:8080/api/books?page=2&size=20",
      "title" : "Last"
    },
    "profile" : {
      "href" : "http://localhost:8080/api/profile/books"
    },
    "search" : {
      "href" : "http://localhost:8080/api/books/search",
      "title" : "Search"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 50,
    "totalPages" : 3,
    "number" : 0
  }
}

All looks fine but we have a 'small' issue here - for each record in this list we have an extra query to the DB that calculate ratings:

select avg(r.rating) as rating, count(r) as readings from Reading r where r.book = ?1

On the large database this can significantly decrease its performance.

To reduce the impact of 1+N queries problem we can try to use a cache. First we prepare the cache for book ratings:

@SpringBootApplication
@EnableCaching
public class ProjectionDemo {
 
    @Bean
    public CacheManager cacheManager() {
        
        Cache bookRatings = new ConcurrentMapCache("bookRatings");
 
        SimpleCacheManager manager = new SimpleCacheManager();
        manager.setCaches(asList(bookRatings));
        
        return manager;
    }
}

Then we add @Cacheable annotation to the repo method:

@RepositoryRestResource
public interface ReadingRepo extends JpaRepository<Reading, Long> {

    @Cacheable(value = "bookRatings", key = "#a0.id")
    @RestResource(exported = false)
    @Query("select avg(r.rating) as rating, count(r) as readings from Reading r where r.book = ?1")
    Reading.Ratings getBookRatings(Book book);
}

To evict not actual data from the cache we can use repository event hadler to catch 'create', 'save' and 'delete' events:

@Slf4j
@RequiredArgsConstructor
@Component
@RepositoryEventHandler(Reading.class)
public class ReadingEventHandler {

    private final @NonNull CacheManager cacheManager;
    
    @HandleAfterCreate
    @HandleAfterSave
    @HandleAfterDelete
    public void evictCaches(Reading reading) {
        Book book = reading.getBook();
        cacheManager.getCache("bookRatings").evict(book.getId());
 
        LOG.info("<<< Ratings caches evicted >>>");
    }
}

Now the second and the next calls of GET http://localhost:8080/api/books?projection=bookRating will take ratings data from the cache.

About

Using Projections in Spring Data REST as DTO with cache to reduce the impact of 1+N queries problem

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages