궤도
[Spring] [면적면적[6]] 나의 서재 책 리스트 가져오기 (각기 다른 dto를 한 response에 넘기기 + MapStruct) 본문
[Spring] [면적면적[6]] 나의 서재 책 리스트 가져오기 (각기 다른 dto를 한 response에 넘기기 + MapStruct)
영이오 2021. 7. 30. 16:32북적북적의 백엔드를 클론하고 있다.
build.gradle
plugins {
id 'org.springframework.boot' version '2.5.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
implementation 'org.projectlombok:lombok:1.18.18'
runtimeOnly 'mysql:mysql-connector-java'
implementation 'com.googlecode.json-simple:json-simple:1.1.1'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
src/main/resources/application.yml
aladin:
url: http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?ttbkey=비밀키&itemIdType=ISBN13&output=js&Version=20131101&ItemId={isbn}
naver:
id: 아이디
secret: 비번
secret:
key: 비번
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3305/데베?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 비번
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
hibernate:
ddl-auto: none
properties:
hibernate:
format_sql: true
show-sql: true
#logging:
# level:
# org:
# hibernate:
# type:
# descriptor:
# sql: trace
server:
servlet:
encoding:
force-response: true
요구사항 정리
나의 서재 열람
[Request] {bookStatus, GET}
1. 입력값 유효성 확인
[Response] {List<Book>, HttpStatus.OK} / {HttpStatus.NO_CONTENT}
원래 나의 서재 -> 책 상세보기를 합쳐서 하려고 했는데 양이 많아서 나눈다.
그리고 이제 글을 좀 대충쓰려고 한다.
혹시 이 글이 도움이 되는 사람이 있다면 깃허브 주소도 있으니까 자세한 path는 거기로 보고...주요한 로직만 대충 설명하려 한다. 난 이제 지쳤어요 땡벌땡벌
나의 서재 열람
일단 request로 받는 bookStatus는 nullable이어야 한다. null이면 전체 리스트를 반환하고, 있으면 거기에 해당하는 책들을 보내주면 되니까...
아무튼 상태 상관없이 공통으로 넘겨줘야 하는 정보는 {id, title, thumbnail, bookStatus}이다. 그리고 책의 상태에 따라서
'읽은 책' : {startDate, endDate, scroe}
'읽고 있는 책' : {startDate, readPage, totPage}
'읽고 싶은 책' : {totPage, score, expectation}
이다.
그래서 이 공통된 부분은
@Getter
@AllArgsConstructor
public abstract class BookshelfBasic {
private Long id;
private String title;
private String author;
private String thumbnail;
private BookStatus bookStatus;
}
추상 클래스로 만들고
public class BookshelfResDtos {
@Getter
public static class DoneBook extends BookshelfBasic{
private LocalDate startDate;
private LocalDate endDate;
private int score;
@Builder
public DoneBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
LocalDate startDate, LocalDate endDate, int score) {
super(id, title, author, thumbnail, bookStatus);
this.startDate = startDate;
this.endDate = endDate;
this.score = score;
}
}
@Getter
public static class ReadingBook extends BookshelfBasic{
private LocalDate startDate;
private int readPage;
private Integer totPage;
@Builder
public ReadingBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
LocalDate startDate, int readPage, Integer totPage) {
super(id, title, author, thumbnail, bookStatus);
this.startDate = startDate;
this.readPage = readPage;
this.totPage = totPage;
}
}
@Getter
public static class WishBook extends BookshelfBasic{
private Integer totPage;
private int score;
private String expectation;
@Builder
public WishBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
Integer totPage, int score, String expectation) {
super(id, title, author, thumbnail, bookStatus);
this.totPage = totPage;
this.score = score;
this.expectation = expectation;
}
}
}
걔를 상속하는 클래스들을 만들었다.
다른 클래스를 상속받고 거기에 빌더를 적용하려면 부모클래스는 AllArgsConstructor를 열어두고, 자식 클래스에 이런 생성자를 만들면 된다고 한다.
/**
* 내 서재 조회
* localhost:8080/bookshelf?bookStatus=done
*
* @param user
* @param bookStatus
* @return ResponseEntity
*/
@GetMapping("")
public ResponseEntity<?> bookshelf(@AuthenticationPrincipal User user,
@RequestParam(required = false) String bookStatus) {
log.info("[Request] Retrieve all books " + user.getEmail());
List<Object> result = bookshelfService.bookshelf(user, bookStatus);
if (result.isEmpty())
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(result, HttpStatus.OK);
}
컨트롤러는 이렇게 만들었는데, 앞서 말했듯이 bookStatus가 null일 때 모든 책을 받아오기로 했으니 required를 false로 놓는다. 사용자의 검색 조건에 따라 해당하는 책 리스트가 없을 수도 있다. 그렇기 때문에 결과 리스트가 비어있다면, NO_CONTENT를 보내주고 아니면 결과와 함께 OK를 보낸다.
/**
* bookShelf
*
* @param user
* @param bookStatus
* @return List<Object>
*/
public List<Object> bookshelf(User user, String bookStatus) {
validateInput(bookStatus);
List<Object> bookList = new ArrayList<>();
user.getBooks().stream()
.sorted(Comparator.comparing(Book::getId).reversed()) //가장 최근에 저장된 책부터 나오도록 정렬
.filter(o -> bookStatus == null || o.getBookStatus() == BookStatus.from(bookStatus)) //검색 조건 필터링
.forEach(o -> {
if (o.getBookStatus() == BookStatus.DONE)
bookList.add(bookshelfMapper.INSTANCE.bookToDoneBook(o));
if (o.getBookStatus() == BookStatus.READING)
bookList.add(bookshelfMapper.INSTANCE.bookToReadingBook(o));
if (o.getBookStatus() == BookStatus.WISH)
bookList.add(bookshelfMapper.INSTANCE.bookToWishBook(o));
});
return bookList;
}
private void validateInput(String bookStatus) { //enum 확인
if (Arrays.stream(BookStatus.values())
.noneMatch(o -> bookStatus == null || o.toString().equalsIgnoreCase(bookStatus)))
throw new InvalidReqParamException("BookStatus = " + bookStatus);
}
서비스 부분 코드이다. 먼저 input이 bookStatus가 맞는지 확인한다.
양방향 매핑으로 사용자에게도 책 리스트를 넣어놨으니, 거기서 빼오면 되고...저장된 책들을 최신순으로 가져오니 id를 기준으로 내림차순 정렬하면 된다. 그리고 bookStatus에 따라서 필터를 하고 그 status에 따라서 각기 다른 dto에 넣는데, 처음엔 빌더 코드를 직접 짜서 엔티티를 dto에 넣어줬다. 근데 이걸 자동으로 해주는 라이브러리가 있었다.
아무튼 이 공식문서를 보면서
package jpa.myunjuk.module.mapper.bookshelf;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.Memo;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import static jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.*;
import static jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.*;
@Mapper(componentModel = "spring")
public interface BookshelfMapper {
BookshelfMapper INSTANCE = Mappers.getMapper(BookshelfMapper.class);
DoneBook toDto(Book book);
BookshelfInfoDto bookToBookshelfInfoDto(Book book);
DoneBook bookToDoneBook(Book book);
ReadingBook bookToReadingBook(Book book);
WishBook bookToWishBook(Book book);
BookshelfMemoResDto memoToBookshelfMemoResDto(Memo memo);
}
스프링 데이터 jpa를 사용할 때처럼 인터페이스에 이름만 선언하면 된다.
사실 DoneBook toDto(Book book);은 사용하지 않는데, 이게 없으면 null로 셋팅이 된다. 아무래도 메소드 이름을 인식하지 못하는 듯 싶다...사실 잘 모르겠지만 아무튼 저런게 있어야 잘 작동해서 넣어놨다.
잘 생성됐다면 빌드 후 generated 폴더에
package jpa.myunjuk.module.mapper.bookshelf;
import javax.annotation.processing.Generated;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.Memo;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfInfoDto;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfInfoDto.BookshelfInfoDtoBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfMemoResDto;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfMemoResDto.BookshelfMemoResDtoBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.DoneBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.DoneBook.DoneBookBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.ReadingBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.ReadingBook.ReadingBookBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.WishBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.WishBook.WishBookBuilder;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2021-07-30T12:34:39+0900",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 14.0.1 (Oracle Corporation)"
)
@Component
public class BookshelfMapperImpl implements BookshelfMapper {
@Override
public DoneBook toDto(Book book) {
if ( book == null ) {
return null;
}
DoneBookBuilder doneBook = DoneBook.builder();
doneBook.id( book.getId() );
doneBook.title( book.getTitle() );
doneBook.author( book.getAuthor() );
doneBook.thumbnail( book.getThumbnail() );
doneBook.bookStatus( book.getBookStatus() );
doneBook.startDate( book.getStartDate() );
doneBook.endDate( book.getEndDate() );
if ( book.getScore() != null ) {
doneBook.score( book.getScore() );
}
return doneBook.build();
}
@Override
public BookshelfInfoDto bookToBookshelfInfoDto(Book book) {
if ( book == null ) {
return null;
}
BookshelfInfoDtoBuilder bookshelfInfoDto = BookshelfInfoDto.builder();
bookshelfInfoDto.id( book.getId() );
bookshelfInfoDto.description( book.getDescription() );
bookshelfInfoDto.publisher( book.getPublisher() );
bookshelfInfoDto.isbn( book.getIsbn() );
bookshelfInfoDto.totPage( book.getTotPage() );
bookshelfInfoDto.url( book.getUrl() );
return bookshelfInfoDto.build();
}
@Override
public DoneBook bookToDoneBook(Book book) {
if ( book == null ) {
return null;
}
DoneBookBuilder doneBook = DoneBook.builder();
doneBook.id( book.getId() );
doneBook.title( book.getTitle() );
doneBook.author( book.getAuthor() );
doneBook.thumbnail( book.getThumbnail() );
doneBook.bookStatus( book.getBookStatus() );
doneBook.startDate( book.getStartDate() );
doneBook.endDate( book.getEndDate() );
if ( book.getScore() != null ) {
doneBook.score( book.getScore() );
}
return doneBook.build();
}
@Override
public ReadingBook bookToReadingBook(Book book) {
if ( book == null ) {
return null;
}
ReadingBookBuilder readingBook = ReadingBook.builder();
readingBook.id( book.getId() );
readingBook.title( book.getTitle() );
readingBook.author( book.getAuthor() );
readingBook.thumbnail( book.getThumbnail() );
readingBook.bookStatus( book.getBookStatus() );
readingBook.startDate( book.getStartDate() );
if ( book.getReadPage() != null ) {
readingBook.readPage( book.getReadPage() );
}
readingBook.totPage( book.getTotPage() );
return readingBook.build();
}
@Override
public WishBook bookToWishBook(Book book) {
if ( book == null ) {
return null;
}
WishBookBuilder wishBook = WishBook.builder();
wishBook.id( book.getId() );
wishBook.title( book.getTitle() );
wishBook.author( book.getAuthor() );
wishBook.thumbnail( book.getThumbnail() );
wishBook.bookStatus( book.getBookStatus() );
wishBook.totPage( book.getTotPage() );
if ( book.getScore() != null ) {
wishBook.score( book.getScore() );
}
wishBook.expectation( book.getExpectation() );
return wishBook.build();
}
@Override
public BookshelfMemoResDto memoToBookshelfMemoResDto(Memo memo) {
if ( memo == null ) {
return null;
}
BookshelfMemoResDtoBuilder bookshelfMemoResDto = BookshelfMemoResDto.builder();
bookshelfMemoResDto.id( memo.getId() );
bookshelfMemoResDto.content( memo.getContent() );
bookshelfMemoResDto.saved( memo.getSaved() );
return bookshelfMemoResDto.build();
}
}
이런게 생긴다. 내가 직접만들 때에는 엔티티에 대한 null 체크도 해야했는데, 이건 null이면 자동으로 null로 넣어줘서 좋은 것 같다! dto->entity도 가능한데 아직 그건 안써봤다.
List<Object> bookList = new ArrayList<>();
user.getBooks().stream()
.sorted(Comparator.comparing(Book::getId).reversed()) //가장 최근에 저장된 책부터 나오도록 정렬
.filter(o -> bookStatus == null || o.getBookStatus() == BookStatus.from(bookStatus)) //검색 조건 필터링
.forEach(o -> {
if (o.getBookStatus() == BookStatus.DONE)
bookList.add(bookshelfMapper.INSTANCE.bookToDoneBook(o));
if (o.getBookStatus() == BookStatus.READING)
bookList.add(bookshelfMapper.INSTANCE.bookToReadingBook(o));
if (o.getBookStatus() == BookStatus.WISH)
bookList.add(bookshelfMapper.INSTANCE.bookToWishBook(o));
});
return bookList;
아무튼 그래서 매퍼는 이런식으로 쓰는 것이고 최종적인 bookList에 어떤 dto가 들어올지 모르니까 Object로 선언했다.
@SpringBootTest
@DisplayName("Bookshelf Service Test")
@Transactional
class BookshelfServiceTest {
@Autowired
BookshelfService bookshelfService;
@Autowired
SearchService searchService;
@Autowired
UserRepository userRepository;
@Autowired
BookRepository bookRepository;
@Autowired
UserCharacterRepository userCharacterRepository;
@Test
@DisplayName("Retrieve all from Bookshelf | Success")
void allBookshelfSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
assertEquals(user.getBooks().size(), bookshelfService.bookshelf(user, null).size());
}
@Test
@DisplayName("Retrieve all from Bookshelf | Fail : Invalid Param")
void allBookshelfFailInvalidParam() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
InvalidReqParamException e = assertThrows(InvalidReqParamException.class, () ->
bookshelfService.bookshelf(user, "wrong"));
assertEquals("BookStatus = wrong", e.getMessage());
}
}
테스트는 간단하게 param에 대한 체크 정도만 했다. 여기서 더 코드를 짜기엔 너무 현재 테스트 유저 상태에 의존을 심하게 하는 듯하여,,,
나름 쪽수와...장르나 이런 다양한걸 고려하다보니 별별 책이 들어갔다.
아무튼 내가 원하는 대로 책 상태에 따라 다른 dto에 들어가 있고, 최근에 저장한 책부터 나오는걸 볼 수 있다!