Isipin natin na nagtatrabaho ka sa iyong web application. Sa aking mga artikulo tinitingnan ko ang mga indibidwal na piraso ng mosaic na ito, tulad ng:
- Pagsubok sa pagsasama ng isang database gamit ang MariaDB upang palitan ang MySql
- Pagpapatupad ng multilinggwal na aplikasyon
@Builder(toBuilder = true)
@Getter
@ToString
public class FileInfo {
private Long id;
private String name;
private Long size;
private String key;
private LocalDate uploadDate;
}
Mag-upload
Tingnan natin ang controller:@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);
}
}
Mga kawili-wiling bagay: 9 - Tinatanggap namin ang file sa form MultipartFile
. Maaari din itong matanggap bilang isang hanay ng mga byte, ngunit mas gusto ko ang opsyong ito, dahil maaari nating MultipartFile
kunin ang iba't ibang katangian ng inilipat na file. 10 - 14 - binabalot namin ang aming mga aksyon try catch
upang kung may maganap na pagbubukod sa mas mababang antas, ipinapasa namin ito nang mas mataas at nagpapadala ng 400 na error bilang tugon. Susunod ay ang antas ng serbisyo:
public interface FileService {
FileInfo upload(MultipartFile resource) throws IOException;
Tingnan natin ang pagpapatupad:
@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 - sa kaso ng isang IOException, ang lahat ng aming mga pag-save sa database ay ibabalik. 11 - bumubuo kami ng isang susi na magiging kakaiba para sa file kapag na-save ito (kahit na naka-save ang dalawang file na may parehong mga pangalan, hindi magkakaroon ng kalituhan). 12 — bumuo kami ng isang entity upang i-save sa database. 17 — hinihimok namin ang entity na may impormasyon sa database. 18 - i-save ang file na may hashed na pangalan. 20 — ibinabalik namin ang nilikhang entity FileInfo
, ngunit kasama ang nabuong id sa database (pag-uusapan natin ito sa ibaba) at ang petsa ng paglikha. Paraan para sa pagbuo ng isang susi sa isang file:
private String generateKey(String name) {
return DigestUtils.md5Hex(name + LocalDateTime.now().toString());
}
Dito namin hash ang pangalan + petsa ng paglikha, na magtitiyak sa aming pagiging natatangi. Dao layer interface:
public interface FileDAO {
FileInfo create(FileInfo file);
Pagpapatupad nito:
@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 — создаём date которую и сохраним. 12 - 21 — сохраняем сущность, но более сложным путем, с явным созданием an object PreparedStatement
, чтобы можно было вытащить сгенерированный id (он его вытягивает не отдельным requestом, а в виде ответных метаданных). 22 - 26 — достраиваем нашу многострадальную сущность и отдаем наверх (на самом деле он его не достраивает, а создаёт новый an object, заполняя переданные поля и копируя остальные с изначального). Давайте посмотрим, How будут сохраняться наши файлы в 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 — принимаем файл в виде массива byteов и отдельно Name, под которым он будет сохранен (наш сгенерированный ключ). 2 - 3 — создаем путь (а в пути прописываем путь плюс наш ключ) и файл по нему. 6 - 7 — создаем поток и пишем туда наши byteы (и оборачиваем это все добро в try-finally
чтобы быть уверенными, что поток точно закроется). Тем не менее, многие из методов могут нам выкинуть IOException. В таком случае, благодаря прописанной в шапке метода проброске, мы прокинем его в контроллер и отдадим 400 статус. Давайте протестируем все это дело в Postman: Как видим, все отлично, ответ 201, ответный JSON пришел в виде нашей сохраняемой сущности в БД, и если заглянем в наше хранorще: БД: увидим, что у нас появилось новое meaning. (=*;*=)
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 — отправляем назад файл, при этом добавив в хедер file name (опять же, полученное из сущности с информацией о файле). Давайте посмотрим глубже. Интерфейс сервиса:
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 и загрузка file, разве что 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
и смотрим, How загружается наш файл:
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();
}
}
Возвращаем файл в виде an object Resource
, а искать будем по ключу. 3 — создаем Resource
по пути + ключ. 4 - 8 — проверяем, что файл по заданному пути не пуст и читаем. Если всё ОК, возвращаем его, а если нет, прокидываем IOException наверх. Проверяем наш метод в Postman: Как видим, он отработал на ОК))
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 — удаляем сам файл из нашего “хранorща”. Интерфейс 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. Удаление самого file:
public void delete(String key) throws IOException {
Path path = Paths.get(DIRECTORY_PATH + key);
Files.delete(path);
}
}
Юзаем в Postman: Смотрим в хранorще: Пусто :) Теперь в БД: Видим, что все good))
Test
Давайте попробуем написать тест под нашFileManager
. Для начала взглянем на структуру тестовой части: mockFile.txt — это файл, с помощью которого мы будем тестить наши операции с file storage. testFileStorage будет заменой нашего хранorща. 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();
}
Здесь мы видим задание тестовых данных. Тест сохранения file:
@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 — с помощью тестовой рефлексии меняем нашу константу в сервисе для задания пути сохранения file. 5 — вызываем проверяемый метод. 7 - 10 — проверяем правильность исполнения сохранения. 11 — удаляем сохраненный файл (мы не должны оставить ниHowих следов).
@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();
}
Тест загрузки file: 3 — опять же, меняем путь для нашего FileManager
. 5 — юзаем проверяемый метод. 7 - 9 — проверяем результат исполнения. Тест удаления file:
@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