Добавление отзывов с фотографиями в S3 с помощью AWS SDK Java

от автора

В этой статье рассмотрим реализацию небольшого функционала — добавление отзывов пользователями с возможностью прикрепить фотографии, загрузить их в облако и получить ссылку файл из S3, а именно Yandex Object Storage, используя AWS SDK Java.

Привет! Меня зовут Никита, я начинающий Java разработчик. В одном из своих учебных проектов создал веб-приложение, которое позволяет пользователям делиться информацией об интересных событиях и находить компанию для участия в них.

Одна из фич приложения — добавление отзывов о посещенных событиях. Как и у многих подобных сервисов, возможность добавлять фотографии к своим отзывам — мастхев. Пытаясь правильно реализовать эту функциональность, не смог найти ни одной статьи, в которой можно углубиться в тему комплексно. Поэтому, собрав всю информацию, которая может пригодиться, спешу поделиться своим решением!

Переходим к делу

Архитектура проекта

Java 11, Maven, Spring Boot, Hibernate, Lombok, Docker, RestTemplate, AWS SDK

Проект разделен на два сервиса:

  • main — содержит функционал по созданию, удалению и получению отзывов и пользователей. В рамках данного тестового проекта в основном сервисе имеем две основные сущности: Comment и User

  • upload — содержит клиент для обработки внутренних запросов из сервиса main и сам функционал добавления фотографий на S3

Для работы с AWS SDK необходимо иметь следующие зависимости в upload-сервисе:

<dependency>     <groupId>com.amazonaws</groupId>     <artifactId>aws-java-sdk</artifactId>     <version>1.12.429</version> </dependency>  <dependency>     <groupId>javax.xml.bind</groupId>     <artifactId>jaxb-api</artifactId>     <version>2.3.1</version> </dependency>

docker-compose.yml:

version: '3.1' services:   upload-service:     build: upload/upload-server     image: upload-server     container_name: upload-server     ports:       - "9090:9090"     environment:       - BUCKET_NAME=your_bucket       - ACCESS_KEY_ID=your_key       - SECRET_KEY=your_secret    s3-service:     build: main     image: main-service     container_name: main-service     ports:       - "8080:8080"     depends_on:       - main-db     environment:       - UPLOAD_SERVER_URL=http://upload-server:9090       - SPRING_DATASOURCE_URL=jdbc:postgresql://main-db:5432/s3-db       - POSTGRES_USER=admin       - POSTGRES_PASSWORD=admin    main-db:     image: postgres:14-alpine     container_name: main-db     ports:       - "6551:5432"     environment:       - POSTGRES_DB=s3-db       - POSTGRES_USER=admin       - POSTGRES_PASSWORD=admin       - TZ=GMT

Так как цель загрузки фотографий на облако — использование их при отображении отзыва в веб-интерфейсе, принято решение хранить ссылки на фотографии в базе данных PostgreSQL с привязкой к отзыву для удобной выгрузки без постоянного обращения к S3.

ER диаграмма

ER диаграмма

Основной сервис

Рассмотрим контроллеры основного сервиса. На создании пользователя останавливаться не буду, важно только учесть, что перед отправкой отзыва хоть какой-то человек должен быть создан (можно в принципе и кота зарегать).

@RestController @RequiredArgsConstructor @RequestMapping(path = "/comments") public class CommentController {      private final CommentService commentService;      /**      * Создание нового отзыва      * @param userId ID пользователя      * @param newCommentDto Данные добавляемого отзыва      * @return Созданный отзыв      */     @PostMapping("/users/{userId}")     @ResponseStatus(HttpStatus.CREATED)     public CommentDto addComment(@PathVariable Long userId, @Validated @ModelAttribute NewCommentDto newCommentDto) {         return commentService.addComment(userId, newCommentDto);     }   }

Взглянем на DTO при создании комментария отзыва, он нам еще пригодится.

@Data @NoArgsConstructor @AllArgsConstructor public class NewCommentDto {      /**      * Текст отзыва      */     @NotNull     @Size(min = 50, max = 2000)     private String text;      /**      * Рейтинг, поставленный в отзыве (от 1 до 5)      */     @NotNull     @Min(1)     @Max(5)     private int rating;      /**      * Фотографии, прикрепленные к отзыву      */     private List<MultipartFile> photos; }

Так как не все пользователи охотно прикрепляют фотографии к своим отзывам, стоит учесть, что его можно создать и без фотографий. В таком случае он создается просто с пустым списком ссылок на фото в ответе. А вот, кстати, и ответ сервера:

@Data @NoArgsConstructor @AllArgsConstructor public class CommentDto {      private Long id;      private String authorName;      private String text;      private String created;      private int rating;      private List<String> photos; }

Пора реализовать добавление отзыва в сервисе. Создаем интерфейс CommentService, описываем его контракт, затем создаем его имплементацию и получаем:

@Service @Slf4j @RequiredArgsConstructor public class CommentServiceImpl implements CommentService {      private final CommentRepository commentRepository;     private final UserService userService;     private final UploadClient uploadClient;      @Override     public CommentDto addComment(Long userId, NewCommentDto newCommentDto) {         User user = userService.getExistingUser(userId);         List<String> photoUrls = new ArrayList<>();          if (newCommentDto.getPhotos() != null) {             ResponseEntity<List<String>> response = uploadClient.upload(newCommentDto.getPhotos());              if (response.getStatusCode().is2xxSuccessful()) {                 List<String> responseBody = response.getBody();                 if (responseBody != null) {                     photoUrls.addAll(responseBody);                 }             } else {                 log.error("Cannot upload photos. Upload service returned status {}", response.getStatusCode());             }         }          Comment comment = CommentMapper.toComment(newCommentDto);         comment.setAuthor(user);         comment.setCreated(LocalDateTime.now());         comment.setPhotos(photoUrls);          Comment addedComment = commentRepository.save(comment);         log.info("Added new comment: comment = {}", addedComment);          return CommentMapper.toCommentDto(addedComment);     }   }

О чем я говорил ранее, пользователи могут писать отзывы и не прикреплять фото, поэтому просто проверяем на наличие загруженных фотографий и если их нет — сохраняем пустой список.

Запрос на добавление отзыва (вспоминаем про DTO при создании) отправляется с заголовком Content-Type: multipart/form-data. Параметр photos соответственно может отсутствовать.

При наличии фотографий мы отправляем их через созданный RestTemplate upload-client, который расщепляет фотографии в байты, и затем отправляет их на upload-server. Последний загружает в Yandex Object Storage, используя многопоточность Java. Двигаемся дальше.

Сервис загрузки фотографий

Получив фотографии с основного сервиса, клиент делает свои небольшие изменения, о которых я говорил выше, и отправляет на сервер запрос на загрузку.

@RestController @RequiredArgsConstructor public class UploadController {      private final UploadService uploadService;      @PostMapping("/upload")     @ResponseStatus(HttpStatus.CREATED)     public List<String> upload(@RequestBody List<byte[]> photos) {         return uploadService.uploadPhoto(photos);     } }

Как там работает AWS SDK

Теперь рассмотрим сам метод загрузки детальнее. Код, представленный ниже, создает подключение к S3, в нашем случае к Yandex Object Storage, используя accessKeyId и secretAccessKey — статические ключи доступа. Инструкция по созданию. Помимо этого в будущем понадобится имя бакета bucketName. В своем приложении я указываю их в docker-compose.yml и инжекчу с помощью аннотации @Value

s3Client = AmazonS3ClientBuilder.standard()                     .withEndpointConfiguration(                             new com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration(                                     "https://storage.yandexcloud.net",                                     "ru-central1"                             )                     )                     .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyId, secretAccessKey)))                     .build();

Чтобы добавить фотографию в бакет, нужно вызвать метод putObject()

s3Client.putObject(bucketName, fileName, inputStream, metadata);
Еще можно так, но не работает с Yandex Object Storage

Аналогичным способом, но через создание объекта PutObjectRequest

s3client.putObject(new PutObjectRequest(bucketName, fileName, inputStream, metadata));

Также можно настроить доступ к чтению файла, если у бакета не включен публичный доступ

s3client.putObject(new PutObjectRequest(bucketName, fileName, inputStream, metadata)                     .withCannedAcl(CannedAccessControlList.PublicRead))

Объект метаданных создается следующим образом. Можно установить заголовок, длину контента и т.д., в зависимости от ваших потребностей.

ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(photoBytes.length);

В моей реализации мне необходимо возвращать список ссылок на загруженные фотографии, чтобы привязать их к отзыву, поэтому давайте достанем ссылку из S3. Это можно сделать методом getUrl()

s3Client.getUrl(bucketName, fileName).toExternalForm();

Хочется производительности?

При поочередной загрузке циклом 10 фотографий в отзыв, у меня это заняло 6 секунд.

Скорость выполнения запроса POST /comments/users/{userId} с загрузкой 10 фотографий поочередно

Скорость выполнения запроса POST /comments/users/{userId} с загрузкой 10 фотографий поочередно

Здесь явно напрашивается выполнение каждой загрузки в отдельном потоке, при этом мы понимаем, что будет в дальнейшем ограничение на количество возможных фотографий к прикреплению. Значит потоками можно легко управлять.

Обращаемся к Java Concurrency. Создаем пул потоков ExecutorService, который будет управлять загрузкой фотографий, а также список Future, который представляет собой обещание будущего результата. Каждая задача загрузки фотографии будет возвращать URL загруженного файла в виде строки, и эти обещания будут использоваться для получения результатов после завершения задач.

ExecutorService executorService = Executors.newFixedThreadPool(photos.size());  List<Future<String>> futures = new ArrayList<>();

Используем executorService.submit() для отправки задач на выполнение в пул потоков. Этот метод принимает Callable<String> (или Runnable) и возвращает Future, представляющий результат выполнения задачи. Внутри лямбда-выражения выполняется код загрузки фотографии и получения URL. Результат (URL) возвращается из задачи.

Future<String> future = executorService.submit(() -> {     // код для загрузки фотографии и получения URL     return url; });  futures.add(future);

После отправки всех задач на выполнение, мы проходим по списку futures и ожидаем завершения каждой задачи с помощью метода future.get(). Этот метод блокирует выполнение до тех пор, пока задача не завершится, и возвращает результат. И, конечно же, в конце вызываем executorService.shutdown() для корректного завершения пула потоков.

В итоге производительность увеличилась в 5 раз. Аналогичное создание отзыва с такими же десятью фотографиями заняло 1 с лишним секунду.

Скорость выполнения запроса POST /comments/users/{userId} с загрузкой 10 фотографий в отдельных потоках

Скорость выполнения запроса POST /comments/users/{userId} с загрузкой 10 фотографий в отдельных потоках

Итоговый код класса UploadService

Таким образом, реализация метода upload() получилась следующим образом:

@Service @Slf4j @RequiredArgsConstructor public class UploadServiceImpl implements UploadService {      private final ObjectStorageConfig objectStorageConfig;      @Override     public List<String> uploadPhoto(List<byte[]> photos) {         List<String> urls = new ArrayList<>();          final String bucketName = objectStorageConfig.getBucketName();         final String accessKeyId = objectStorageConfig.getAccessKeyId();         final String secretAccessKey = objectStorageConfig.getSecretAccessKey();         final AmazonS3 s3Client;          try {             s3Client = AmazonS3ClientBuilder.standard()                     .withEndpointConfiguration(                             new com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration(                                     "https://storage.yandexcloud.net",                                     "ru-central1"                             )                     )                     .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyId, secretAccessKey)))                     .build();         } catch (SdkClientException e) {             log.error("Error creating client for Object Storage via AWS SDK. Reason: {}", e.getMessage());             throw new SdkClientException(e.getMessage());         }          try {             ExecutorService executorService = Executors.newFixedThreadPool(photos.size());             List<Future<String>> futures = new ArrayList<>();              for (byte[] photoBytes : photos) {                 Future<String> future = executorService.submit(() -> {                     String fileName = generateUniqueName();                     ObjectMetadata metadata = new ObjectMetadata();                     metadata.setContentLength(photoBytes.length);                      ByteArrayInputStream inputStream = new ByteArrayInputStream(photoBytes);                     s3Client.putObject(bucketName, fileName, inputStream, metadata);                     log.info("Upload Service. Added file: " + fileName + " to bucket: " + bucketName);                      // Получение ссылки на загруженный файл                     String url = s3Client.getUrl(bucketName, fileName).toExternalForm();                      return url;                 });                  futures.add(future);             }                        for (Future<String> future : futures) {                 try {                     String url = future.get();                     urls.add(url);                 } catch (InterruptedException | ExecutionException e) {                     log.error("One of the thread ended with exception. Reason: {}", e.getMessage());                     throw new RuntimeException(e);                 }             }              executorService.shutdown();         } catch (AmazonS3Exception e) {             log.error("Error uploading photos to Object Storage. Reason: {}", e.getMessage());             throw new AmazonS3Exception(e.getMessage());         }         return urls;     }      private String generateUniqueName() {         UUID uuid = UUID.randomUUID();         return uuid.toString();     } }

Заключение

Использование AWS SDK для Java позволяет легко интегрировать возможности Yandex Object Storage или Amazon S3 в ваши приложения, обеспечивая надежное и масштабируемое хранение и доступ к файлам. Правильная настройка и использование этого SDK может значительно упростить задачи работы с объектными хранилищами, позволяя сосредоточиться на разработке вашего приложения.

Надеюсь, у меня получилось создать небольшой гайд и раскрыть данную тему. Весь код разместил у себя в GitHub. Вы можете посмотреть его подробнее и запустить это решение локально.

Спасибо всем, кто дочитал до конца! Буду рад вашим комментариям, советам и критике, а также best-practice в реализации данного функционала в коммерческой разработке!


ссылка на оригинал статьи https://habr.com/ru/articles/762800/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *