JavaRush /Java блог /Java Developer /Сохранение файлов в приложение и данных о них на БД
Константин
36 уровень

Сохранение файлов в приложение и данных о них на БД

Статья из группы Java Developer
Сохранение файлов в приложение и данных о них на БД - 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 c использованием 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>
На этом у меня сегодня всё))
Комментарии (5)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Олег Уровень 24
27 июля 2020
Exception in thread "task-2" org.springframework.beans.factory.BeanCreationNotAllowedException: Error creating bean with name 'springApplicationAdminRegistrar': Singleton bean creation not allowed while singletons of this factory are in destruction (Do not request a bean from a BeanFactory in a destroy method implementation!)
Олег Уровень 24
27 июля 2020
у меня ошибка вышла при компиляции: Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled. 2020-07-27 14:35:36.929 ERROR 9668 --- [ restartedMain] o.s.b.d.LoggingFailureAnalysisReporter :
Andrei Уровень 41
18 января 2020
1. В случае с фелом заливки или удаления - у тебя создасться запись в БД, а потом удалится. Т.е. произойдет не нужная работа. ИМХО: сначала лучше выполнить действия с файлом, а затем выполнять действия с БД. 2. Ну и простое решение для генерации ключа - java.util.UUID.randomUUID()
Goryachev Sergey Уровень 4
16 января 2020
для меня это пока что сложно. Но спасибо!
MartyMcAir Уровень 41
15 января 2020
спасибо, годный контент.