ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 1 بیایید تصور کنیم که روی برنامه وب خود کار می کنید. در مقالات خود به تک تک قطعات این موزاییک نگاه می کنم، مانند: این موضوعات چگونه مفید هستند؟ و اینکه این نمونه ها به کار روی پروژه های واقعی بسیار نزدیک هستند و تست این موضوعات برای شما بسیار مفید خواهد بود. امروز قطعه بعدی این موزاییک را می گیریم - کار با فایل ها، زیرا امروزه دیگر نمی توانید سایتی را پیدا کنید که با آنها تعامل نداشته باشد (به عنوان مثال، انواع فروشگاه های اینترنتی، شبکه های اجتماعی و غیره). بررسی با استفاده از روش‌های آپلود/دانلود/حذف به عنوان مثال انجام می‌شود ؛ ما آن را در یک پوشه (در منبع) در برنامه خود ذخیره می‌کنیم تا پیچیده نشود. ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 2ایده این است که ما علاوه بر خود فایل در به اصطلاح ذخیره سازی فایل خود، موجودی را با اطلاعات مربوط به فایل خود (اندازه، نام و غیره) در پایگاه داده خود ذخیره کنیم - یک جدول از پیش ساخته شده. یعنی هنگام بارگذاری یک فایل، این موجودیت برای ما بسیار مفید خواهد بود و در هنگام حذف به هیچ وجه نباید آن را فراموش کنیم. بیایید به این جدول نگاهی بیندازیم: ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 3و بیایید شناسه را مانند بزرگسالان روی 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;
}

بارگذاری

بیایید به کنترلر نگاه کنیم:
@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، اما با شناسه تولید شده در پایگاه داده (این مورد در زیر مورد بحث قرار خواهد گرفت) و تاریخ ایجاد. روش تولید کلید یک فایل:
private String generateKey(String name) {
   return DigestUtils.md5Hex(name + LocalDateTime.now().toString());
}
در اینجا نام + تاریخ ایجاد را هش می کنیم که منحصر به فرد بودن ما را تضمین می کند. رابط لایه دائو:
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به طوری که شناسه تولید شده را بتوان بیرون کشید (آن را نه با یک درخواست جداگانه، بلکه در قالب فراداده پاسخ بیرون می کشد. ). 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 به شکل موجودیت پایدار ما در پایگاه داده آمده است، و اگر به فضای ذخیره سازی خود نگاه کنیم: DB: می ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 6بینیم که ما یک ارزش جدید داریم (=*;*=)

دانلود

کنترل کننده:
@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);
}
هیچ چیز جالبی در اینجا وجود ندارد: روش جستجوی یک موجودیت با شناسه و بارگیری فایل، به جز شاید 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و 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همانطور که می بینید، خوب کار کرد))

حذف

@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 استفاده می کنیم: ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 8ما در فضای ذخیره سازی نگاه می کنیم: ذخیره فایل ها در برنامه و داده های مربوط به آنها در پایگاه داده - 9Empty :) اکنون در پایگاه داده: Сохранение файлов в приложение и данных о них на БД - 10می بینیم که همه چیز خوب است))

تست

بیایید سعی کنیم یک تست برای خود بنویسیم 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>
امروز برای من همین است))