В этой статье рассмотрим реализацию небольшого функционала - добавление отзывов пользователями с возможностью прикрепить фотографии, загрузить их в облако и получить ссылку файл из 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.

Основной сервис
Рассмотрим контроллеры основного сервиса. На создании пользователя останавливаться не буду, важно только учесть, что перед отправкой отзыва хоть какой-то человек должен быть создан (можно в принципе и кота зарегать).
@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 секунд.

Здесь явно напрашивается выполнение каждой загрузки в отдельном потоке, при этом мы понимаем, что будет в дальнейшем ограничение на количество возможных фотографий к прикреплению. Значит потоками можно легко управлять.
Обращаемся к 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 с лишним секунду.

Итоговый код класса 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 в реализации данного функционала в коммерческой разработке!
