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

Комментарии (3)


  1. aleksandy
    23.09.2023 15:10

    Создавать пулы потоков во время работы приложения не есть хорошо. И совсем нехорошо - создавать их, используя в настройках входные данные. Пул должен создаваться во время старта и выключаться вместе с приложением.

    вызываем executorService.shutdown() для корректного завершения пула потоков

    Корректное завершение пула потоков приведено, ВНЕЗАПНО!, в документации к основному интерфейсу.


    1. nickpominov Автор
      23.09.2023 15:10

      Спасибо за совет!


  1. LeshaRB
    23.09.2023 15:10

    А почему Future, а не CompletableFuture?

    И ещё вопрос, если пользователь добавит 100500 фото
    Executors.newFixedThreadPool(photos.size())