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