당신이 웹 애플리케이션 작업을 하고 있다고 상상해 보자. 내 기사에서는 다음과 같은 모자이크의 개별 조각을 살펴봅니다.
이 주제는 어떻게 유용합니까? 그리고 이러한 예제가 실제 프로젝트 작업과 매우 유사하다는 사실과 이러한 주제를 테스트하는 것은 여러분에게 매우 유용할 것입니다. 오늘 우리는 이 모자이크의 다음 부분을 다룰 것입니다. 즉, 파일 작업입니다. 요즘에는 파일과 상호 작용하지 않는 사이트(예: 모든 종류의 웹 상점, 소셜 네트워크 등)를 더 이상 찾을 수 없기 때문입니다. 검토는 업로드/다운로드/삭제 방법을 예로 사용하여 수행되며 복잡하지 않도록 애플리케이션의 폴더(리소스)에 저장합니다. 아이디어는 소위 파일 저장소에 있는 파일 자체 외에도 파일에 대한 정보(크기, 이름 등)가 포함된 엔터티를 데이터베이스(미리 생성된 테이블)에 저장한다는 것입니다. 즉, 파일을 로드할 때 이 엔터티는 우리에게 매우 유용할 것이며, 삭제할 때 어떤 식으로든 잊어서는 안 됩니다. 이 테이블을 살펴보겠습니다. 그리고 성인처럼 ID를 AUTO_INCREMENT로 설정하여 데이터베이스 수준에서 자동으로 식별자를 생성해 보겠습니다. 먼저 구조를 살펴보겠습니다. 위에 표시된 테이블 아래의 엔터티는 다음과 같습니다.
@Builder(toBuilder = true)
@Getter
@ToString
public class FileInfo {
private Long id;
private String name;
private Long size;
private String key;
private LocalDate uploadDate;
}
업로드
컨트롤러를 살펴보겠습니다:@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
@PostMapping
public ResponseEntity<FileInfo> upload(@RequestParam MultipartFile attachment) {
try {
return new ResponseEntity<>(fileService.upload(attachment), HttpStatus.CREATED);
} catch (IOException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
흥미로운 점: 9 - 우리는 형식으로 파일을 받아들입니다 MultipartFile
. 바이트 배열로 수신할 수도 있지만 MultipartFile
전송된 파일의 다양한 속성을 추출할 수 있기 때문에 이 옵션이 더 마음에 듭니다. 10 - 14 - try catch
낮은 수준에서 예외가 발생하면 더 높은 수준으로 전달하고 응답으로 400 오류를 보낼 수 있도록 작업을 래핑합니다. 다음은 서비스 수준입니다.
public interface FileService {
FileInfo upload(MultipartFile resource) throws IOException;
구현을 살펴보겠습니다.
@Service
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final FileDAO fileDAO;
private final FileManager fileManager;
@Transactional(rollbackFor = {IOException.class})
@Override
public FileInfo upload(MultipartFile resource) throws IOException {
String key = generateKey(resource.getName());
FileInfo createdFile = FileInfo.builder()
.name(resource.getOriginalFilename())
.key(key)
.size(resource.getSize())
.build();
createdFile = fileDAO.create(createdFile);
fileManager.upload(resource.getBytes(), key);
return createdFile;
}
8 - IOException이 발생하면 데이터베이스에 저장된 모든 내용이 롤백됩니다. 11 - 파일이 저장될 때 해당 파일에 대해 고유한 키를 생성합니다(동일한 이름을 가진 두 개의 파일이 저장되더라도 혼동이 발생하지 않습니다). 12 — 데이터베이스에 저장할 엔터티를 만듭니다. 17 — 정보가 포함된 엔터티를 데이터베이스로 이동합니다. 18 - 해시된 이름으로 파일을 저장합니다. 20 — 생성된 엔터티를 반환 FileInfo
하지만 데이터베이스에 생성된 ID(이 내용은 아래에서 설명함)와 생성 날짜를 반환합니다. 파일에 대한 키를 생성하는 방법:
private String generateKey(String name) {
return DigestUtils.md5Hex(name + LocalDateTime.now().toString());
}
여기서는 이름 + 생성 날짜를 해시하여 고유성을 보장합니다. Dao 레이어 인터페이스:
public interface FileDAO {
FileInfo create(FileInfo file);
구현:
@Repository
@RequiredArgsConstructor
public class FileDAOImpl implements FileDAO {
private static final String CREATE_FILE = "INSERT INTO files_info(file_name, file_size, file_key, upload_date) VALUES (?, ?, ?, ?)";
private final JdbcTemplate jdbcTemplate;
@Override
public FileInfo create(final FileInfo file) {
LocalDate uploadDate = LocalDate.now();
GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(x -> {
PreparedStatement preparedStatement = x.prepareStatement(CREATE_FILE, Statement.RETURN_GENERATED_KEYS);
preparedStatement.setString(1, file.getName());
preparedStatement.setLong(2, file.getSize());
preparedStatement.setString(3, file.getKey());
preparedStatement.setDate(4, Date.valueOf(uploadDate));
return preparedStatement;
}, keyHolder);
return file.toBuilder()
.id(keyHolder.getKey().longValue())
.uploadDate(uploadDate)
.build();
}
11 - 저장할 날짜를 만듭니다. 12 - 21 - 엔터티를 저장하지만 PreparedStatement
생성된 ID를 가져올 수 있도록 개체를 명시적으로 생성하여 더 복잡한 방법으로 저장합니다(별도의 요청이 아닌 응답 메타데이터 형식으로 가져옵니다). ). 22 - 26 - 우리는 오래 참음 엔터티의 구성을 완료하고 이를 맨 위에 제공합니다(실제로 그는 그것을 완료하지 않고 새 객체를 생성하여 전송된 필드를 채우고 나머지는 원본 필드에서 복사합니다) . 파일이 다음 위치에 어떻게 저장되는지 살펴보겠습니다 FileManager
.
public void upload(byte[] resource, String keyName) throws IOException {
Path path = Paths.get(DIRECTORY_PATH, keyName);
Path file = Files.createFile(path);
FileOutputStream stream = null;
try {
stream = new FileOutputStream(file.toString());
stream.write(resource);
} finally {
stream.close();
}
}
1 - 파일을 바이트 배열로 받아들이고 파일이 저장될 이름(생성된 키)을 별도로 받아들입니다. 2 - 3 - 경로를 만들고(경로에 경로와 키를 함께 작성) 파일을 만듭니다. 6 - 7 - 스트림을 생성하고 거기에 바이트를 씁니다(그리고 try-finally
스트림이 확실히 닫힐 수 있도록 이 모든 항목을 래핑합니다). 그러나 많은 메소드에서 IOException이 발생할 수 있습니다. 이 경우 메소드 헤더에 지정된 전달 덕분에 이를 컨트롤러에 전달하고 상태 400을 제공합니다. Postman에서 모든 것을 테스트해 보겠습니다. 보시다시피 모든 것이 정상입니다. 응답은 201이고 응답 JSON은 데이터베이스에 지속된 엔터티의 형태로 왔습니다. 스토리지를 살펴보면 DB : 우리에게는 새로운 가치가 있습니다. (=*;*=)
다운로드
제어 장치:@GetMapping(path = "/{id}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Resource> download(@PathVariable("id") Long id) {
try {
FileInfo foundFile = fileService.findById(id);
Resource resource = fileService.download(foundFile.getKey());
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=" + foundFile.getName())
.body(resource);
} catch (IOException e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
또한 이를 래핑 try-catch
하고 IOException이 발생하는 경우 404 응답 상태(찾을 수 없음)를 보냅니다. 4 - 데이터베이스에서 인접한 FileInfo 엔터티를 꺼냅니다. 5 - 엔터티의 키를 사용하여 파일을 다운로드합니다. 6-8 - 파일을 다시 보내고 헤더에 파일 이름을 추가합니다(다시 파일에 대한 정보가 있는 엔터티에서 가져옴). 좀 더 자세히 살펴보겠습니다. 서비스 인터페이스:
Resource download(String key) throws IOException;
FileInfo findById(Long fileId);
구현:
@Override
public Resource download(String key) throws IOException {
return fileManager.download(key);
}
@Transactional(readOnly = true)
@Override
public FileInfo findById(Long fileId) {
return fileDAO.findById(fileId);
}
여기에는 특별히 흥미로운 것이 없습니다. ID로 엔터티를 검색하고 파일을 다운로드하는 방법(아마도 46을 제외하고) - 읽을 트랜잭션이 있음을 표시합니다. 다오 레벨:
FileInfo findById(Long fileId);
구현:
private static final String FIND_FILE_BY_ID = "SELECT id, file_name, file_size, file_key, upload_date FROM files_info WHERE id = ?";
@Override
public FileInfo findById(Long fileId) {
return jdbcTemplate.queryForObject(FIND_FILE_BY_ID, rowMapper(), fileId);
}
private RowMapper<FileInfo> rowMapper() {
return (rs, rowNum) -> FileInfo.builder()
.id(rs.getLong("id"))
.name(rs.getString("file_name"))
.size(rs.getLong("file_size"))
.key(rs.getString("file_key"))
.uploadDate(rs.getObject("upload_date", LocalDate.class))
.build();
}
4 — jdbcTemplate
및 를 사용하여 ID로 검색합니다 RowMapper
. 8 - 15 - RowMapper
데이터베이스 및 모델 필드의 데이터를 비교하기 위한 특정 사례에 대한 구현입니다. FileManager
파일이 어떻게 로드되는지 살펴보겠습니다 .
public Resource download(String key) throws IOException {
Path path = Paths.get(DIRECTORY_PATH + key);
Resource resource = new UrlResource(path.toUri());
if (resource.exists() || resource.isReadable()) {
return resource;
} else {
throw new IOException();
}
}
파일을 객체로 반환 Resource
하고 키로 검색합니다. 3 - Resource
경로 + 키를 따라 생성합니다. 4 - 8 — 주어진 경로의 파일이 비어 있지 않은지 확인하고 읽습니다. 모든 것이 정상이면 반환하고, 그렇지 않으면 맨 위에 IOException을 발생시킵니다. Postman에서 방법을 확인해 보겠습니다. 보시다시피 제대로 작동했습니다.))
삭제
@DeleteMapping(value = "/{id}")
public ResponseEntity<Void> delete(@PathVariable("id") Long id) {
try {
fileService.delete(id);
return new ResponseEntity<>(HttpStatus.OK);
} catch (IOException e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
여기에는 특별한 것이 없습니다. 를 사용하여 실패한 경우에도 404를 반환합니다 try-catch
. 서비스 인터페이스:
void delete(Long fileId) throws IOException;
구현:
@Transactional(rollbackFor = {IOException.class})
@Override
public void delete(Long fileId) throws IOException {
FileInfo file = fileDAO.findById(fileId);
fileDAO.delete(fileId);
fileManager.delete(file.getKey());
}
1 - IOException이 발생하면 데이터 변경(삭제)도 롤백됩니다. 5 - 데이터베이스에서 파일에 대한 정보를 삭제합니다. 6 - "저장소"에서 파일 자체를 삭제합니다. 다오 인터페이스:
void delete(Long fileId);
구현:
private static final String DELETE_FILE_BY_ID = "DELETE FROM files_info WHERE id = ?";
@Override
public void delete(Long fileId) {
jdbcTemplate.update(DELETE_FILE_BY_ID, fileId);
}
그런 건 없어요. 그냥 삭제하세요. 파일 자체 삭제:
public void delete(String key) throws IOException {
Path path = Paths.get(DIRECTORY_PATH + key);
Files.delete(path);
}
}
Postman을 사용합니다. 저장소를 살펴봅니다. 비어 있습니다. :) 이제 데이터베이스에서 모든 것이 정상임을 확인합니다.))
시험
에 대한 테스트를 작성해 보겠습니다FileManager
. 먼저 테스트 부분의 구조를 살펴보겠습니다. mockFile.txt는 파일 저장소를 사용하여 작업을 테스트할 파일입니다. testFileStorage는 우리의 저장소를 대체할 것입니다. FileManagerTest
:
public class FileManagerTest {
private static MultipartFile multipartFile;
private static FileManager manager;
private static FileInfo file;
@BeforeClass
public static void prepareTestData() throws IOException {
file = FileInfo.builder()
.id(9L)
.name("mockFile.txt")
.key("mockFile.txt")
.size(38975L)
.uploadDate(LocalDate.now())
.build();
multipartFile = new MockMultipartFile("mockFile", "mockFile.txt", "txt",
new FileInputStream("src/test/resources/mockFile.txt"));
manager = new FileManager();
}
여기에서 테스트 데이터 할당을 볼 수 있습니다. 파일 저장 테스트:
@Test
public void uploadTest() throws IOException {
ReflectionTestUtils.setField(manager, "DIRECTORY_PATH", "src/test/resources/testFileStorage/");
manager.upload(multipartFile.getBytes(), "mockFile.txt");
Path checkFile = Paths.get("src/test/resources/testFileStorage/mockFile.txt");
assertThat(Files.exists(checkFile)).isTrue();
assertThat(Files.isRegularFile(checkFile)).isTrue();
assertThat(Files.size(checkFile)).isEqualTo(multipartFile.getSize());
Files.delete(checkFile);
}
3 - 테스트 반영을 사용하여 서비스의 상수를 변경하여 파일을 저장할 경로를 설정합니다. 5 — 테스트 중인 메서드를 호출합니다. 7 - 10 - 저장이 올바르게 실행되었는지 확인합니다. 11 - 저장된 파일을 삭제합니다(어떤 흔적도 남기지 않아야 합니다).
@Test
public void downloadTest() throws IOException {
ReflectionTestUtils.setField(manager, "DIRECTORY_PATH", "src/test/resources/");
Resource resource = manager.download(file.getKey());
assertThat(resource.isFile()).isTrue();
assertThat(resource.getFilename()).isEqualTo(file.getName());
assertThat(resource.exists()).isTrue();
}
파일 업로드 테스트: 3 - 다시 FileManager
. 5 - 테스트 중인 방법을 사용합니다. 7 - 9 — 실행 결과를 확인합니다. 파일 삭제 테스트:
@Test
public void deleteTest() throws IOException {
Path checkFile = Paths.get("src/test/resources/testFileStorage/mockFile.txt");
Files.createFile(checkFile);
assertThat(Files.exists(checkFile)).isTrue();
assertThat(Files.isRegularFile(checkFile)).isTrue();
ReflectionTestUtils.setField(manager, "DIRECTORY_PATH", "src/test/resources/testFileStorage/");
manager.delete(file.getKey());
assertThat(Files.notExists(checkFile)).isTrue();
}
9 - 3 - 4 - 경로를 설정하고 파일을 생성합니다. 5 - 6 - 그 존재를 확인합니다. 9 - 검증 가능한 방법을 사용합니다. 77 - 객체가 더 이상 존재하지 않는지 확인합니다. 그리고 종속성 측면에서 무엇을 가지고 있는지 살펴보겠습니다.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.18</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.9</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13-rc-2</version>
<scope>test</scope>
</dependency>
</dependencies>
오늘은 그게 전부입니다))
GO TO FULL VERSION