![Сохранение файлов в приложение и данных о них на БД - 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>
На этом у меня сегодня всё))
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ