JavaRush /Java блог /Random UA /Збереження файлів у додаток та даних про них на БД
Константин
36 рівень

Збереження файлів у додаток та даних про них на БД

Стаття з групи Random UA
Збереження файлів у додаток та даних про них на БД - 1 Давай уявімо, що ти працюєш над своїм веб-додатком. У своїх статтях я розглядаю окремі шматочки цієї мозаїки, як-от: Чим ці теми корисні? А тим, що ці приклади дуже близькі до роботи над реальними проектами, і промацати ці теми буде дуже корисним для тебе. Сьогодні ми візьмемо наступний шматочок даної мозаїки - роботу з файлуми, тому що в наш час вже й не зустріти сайту, який не взаємодіє з ними (наприклад, всякі там веб-шопи, соцмережі і так далі). Огляд буде проводитись на прикладі методів upload/download/delete , зберігати ми будемо в папку (в resource) у нашому додатку, щоб не ускладнювати. Збереження файлів у додаток та даних про них на БД - 2Задум полягає в тому, що ми зберігатимемо, крім самого файлу в нашому так званому file storage (сховищі), сутність з інформацією про наш файл (size, name і т.д.) у нашу БД — попередньо створену таблицю. Тобто при завантаженні файлу нам ця сутність дуже знадобиться, та й при delete про неї не можна жодним чином забувати. Пробіжимось поглядом по цій таблиці: Збереження файлів у додаток та даних про них на БД - 3І давайте вже як дорослі поставимо на id AUTO_INCREMENT, для автогенерування індефікатора на рівні БД. Спочатку поглянемо на нашу структуру: Збереження файлів у додаток та даних про них на БД - 4Сутність під продемонстровану вище таблицю:
@Builder(toBuilder = true)
@Getter
@ToString
public class FileInfo {

   private Long id;

   private String name;

   private Long size;

   private String key;

   private LocalDate uploadDate;
}

Upload

Дивимося контролер:
@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: Збереження файлів у додаток та даних про них на БД - 5Як бачимо, все відмінно, відповідь 201, відповідь JSON прийшов у вигляді нашої сутності, що зберігається в БД, і якщо заглянемо в наше сховище: БД: Збереження файлів у додаток та даних про них на БД - 6побачимо, що у нас з'явилося нове значення. (=*;*=)

Download

Controller:
@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 - помічаємо, що транзакція у нас для читання. Рівень dao:
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 — пошук id з використанням jdbcTemplateі 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: Збереження файлів у додаток та даних про них на БД - 7Як бачимо, він відпрацював на ОК))

Delete

@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;
Implementation:
@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 - видаляємо сам файл із нашого "сховища". Інтерфейс dao:
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);
}
Нічого такого просто delete. Видалення файлу:
public void delete(String key) throws IOException {
       Path path = Paths.get(DIRECTORY_PATH + key);
       Files.delete(path);
   }
}
Юзаєм у Postman: Збереження файлів у додаток та даних про них на БД - 8Дивимося у сховище: Збереження файлів у додаток та даних про них на БД - 9Пусто :) Тепер у БД: Збереження файлів у додаток та даних про них на БД - 10Бачимо, що все good))

Test

Давайте спробуємо написати тест під наш FileManager. Спочатку поглянемо на структуру тестової частини: mockFile.txt — це файл, за допомогою якого ми тестуватимемо наші операції з file storage. 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>
На цьому у мене сьогодні все))
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ