Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Перейти к содержанию
V1 V2

Загрузка и хранение файлов с S3

Это руководство знакомит с S3-совместимым файловым хранилищем в HTTP-приложении Kora. В нем рассматривается, как маршруты загрузки и скачивания принимают multipart-данные, как декларативный S3-клиент Kora сохраняет объекты в корзину и как службы приложения отделяют метаданные файлов от задач объектного хранилища. Вы также увидите, как локальная инфраструктура MinIO дает ту же форму API, что и промышленное S3-хранилище.

Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java S3 App.

Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin S3 App.

Что вы создадите

В этом руководстве вы расширите HTTP-приложение небольшим API файлового хранилища поверх S3-совместимого хранилища.

К концу ваше приложение будет поддерживать:

  • multipart-загрузку файла через POST /files/upload
  • получение списка файлов через GET /files
  • скачивание файла через GET /files/{fileId}
  • удаление файла через DELETE /files/{fileId}
  • декларативный S3-клиент, построенный на @S3.Client
  • стартовый компонент, создающий корзину через клиент AWS SDK
  • локальную разработку и тесты поверх MinIO как S3-совместимого бэкенда

Что понадобится

  • JDK 25 или новее
  • Gradle 9+
  • Docker для локальных запусков MinIO и тестов в контейнерах
  • текстовый редактор или среда разработки
  • пройденное руководство HTTP-сервер

Требования

Обязательно: пройдите продвинутое руководство по HTTP-серверу

Это руководство предполагает, что вы прошли HTTP-сервер продвинутый и у вас уже есть приложение Kora с Application, UserController, DataController, общей обвязкой HTTP-сервера и знакомством с FormMultipart, JSON-ответами и контроллерами Kora.

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

Обзор

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

Это различие важно, потому что у объектного хранилища другая модель доступа. Вы не обновляете отдельные столбцы и не запрашиваете объекты через SQL. Вы кладете объект по ключу, получаете его по ключу, перечисляете ключи и удаляете ключи. Приложение само решает, как эти операции хранилища выглядят через HTTP.

Что такое S3

S3 — это API объектного хранилища и стандарт экосистемы, начавшийся с Amazon S3 и теперь поддерживаемый многими совместимыми системами, включая MinIO.

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

На практике S3 обычно играет роль рядом с базой данных, а не вместо нее:

  • база данных хранит структурированные бизнес-данные: пользователей, заказы, права и ссылки на файлы
  • S3-хранилище хранит само содержимое файла, обычно по ключу объекта

Такое разделение очень распространено в реальных системах, потому что дает лучшую эксплуатационную модель:

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

Типичные реальные сценарии S3:

  • загруженные пользователями аватары, вложения, PDF и таблицы
  • изображения товаров и медиакаталоги
  • сгенерированные счета, отчеты и выгрузки
  • архивы логов и снимки резервных копий
  • промежуточные файлы для аналитики и конвейеров машинного обучения
  • публичные или приватные статические ресурсы, раздаваемые через CDN

Почему команды часто предпочитают хранилища в стиле S3 для файлов:

  • они хорошо масштабируются по числу и размеру объектов
  • модель доступа простая: сохранить по ключу, получить по ключу, удалить по ключу, перечислить по префиксу
  • метаданные объекта и тип содержимого естественно путешествуют вместе с файлом
  • облачные и self-hosted экосистемы уже дают зрелый инструментарий вокруг S3-совместимых хранилищ

Понятия объектного хранилища

Основные понятия S3 в этом руководстве:

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

В отличие от строки базы данных, объект обычно читается и пишется как поток байтов. Из-за этого эндпоинты загрузки и скачивания отличаются от JSON CRUD-маршрутов.

HTTP-границы загрузки и скачивания

Файловые API часто смешивают HTTP и хранилище. HTTP-слой принимает multipart-данные, отдает ответы на скачивание и отображает операции удаления и списка на маршруты. S3-клиент занимается операциями над объектами: put, get, list и delete. Четкая граница между ними не дает коду контроллера превратиться в реализацию хранилища.

Это руководство сосредоточено на небольшом API файлового хранилища:

  • загрузить файл из multipart-запроса
  • перечислить сохраненные объекты
  • скачать объект по ключу
  • удалить объект по ключу

Два независимых артефакта S3

Kora 2.0 поставляет два артефакта S3. Они независимы, не зависят друг от друга, и выбор между ними — первое решение, которое принимает это руководство.

Артефакт Что публикует Берите его, когда
io.koraframework.experimental:s3-client-kora Декларативные интерфейсы @S3.Client, S3Client Нужны типизированные генерируемые операции хранилища и AWS SDK не нужен в classpath
io.koraframework:s3-client-aws software.amazon.awssdk.services.s3.S3Client Нужна вся поверхность AWS SDK: администрирование корзин, копирование, presigned URL, ACL

Декларативный клиент построен на собственном HTTP-клиенте Kora и вообще не зависит от AWS SDK. Он покрывает операции над объектами, которые приложение выполняет во время обработки запроса — put, get, head, list, delete, — и ничего сверх того. Администрирование корзин намеренно вне его контракта.

Артефакт AWS — тонкая обертка: он публикует настоящий S3Client из AWS SDK как компонент графа, настроенный из конфигурации Kora и работающий поверх HTTP-клиента Kora. Все, что умеет SDK, умеет и он.

Это руководство использует оба, и это распространенная промышленная форма: декларативный клиент для пути запроса и клиент AWS SDK один раз на старте, чтобы убедиться, что корзина существует. Полную картину смотрите в Использовании обоих артефактов.

Артефакта s3-client-minio не существует

Kora 2.0 не поставляет специфичного для MinIO клиента, и ни одному из артефактов он не нужен. MinIO говорит на API S3, поэтому здесь он используется исключительно как S3-совместимый сервер для локальных запусков и тестов — а это совсем не то же самое, что клиентская библиотека MinIO.

Практический порядок такой:

  1. добавить оба артефакта S3 и HTTP-клиент, на котором они работают
  2. настроить клиент AWS по пути s3client.aws и один декларативный клиент по пути s3client.uploads
  3. объявить интерфейс @S3.Client и указать его @S3.Bucket на имя настроенной корзины
  4. создать корзину на старте через клиент AWS SDK
  5. отобразить multipart-загрузки на запись объектов, а маршруты скачивания — на чтение объектов
  6. проверить то же поведение поверх MinIO в тестах

Локальный MinIO и промышленная форма

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

Зависимости

Мы строим поверх существующего HTTP-приложения, поэтому добавляем два артефакта S3 и модуль HTTP-клиента, на котором работают оба.

build.gradle
dependencies {
    // ... existing dependencies ...

    implementation("io.koraframework:http-client-ok")
    implementation("io.koraframework:s3-client-aws")
    implementation("io.koraframework.experimental:s3-client-kora")
}
build.gradle.kts
dependencies {
    // ... existing dependencies ...

    implementation("io.koraframework:http-client-jdk")
    implementation("io.koraframework:s3-client-aws")
    implementation("io.koraframework.experimental:s3-client-kora")
}

Обратите внимание на groupId. Декларативный клиент пока экспериментальный и потому публикуется под io.koraframework.experimental, а обертка AWS — стабильный артефакт под обычным io.koraframework. Версии обоих приходят из платформы io.koraframework:kora-bom, поэтому ни одна строка не несет версии.

Модуль HTTP-клиента обязателен для обоих. Ни один артефакт S3 не открывает собственных сокетов: декларативный клиент говорит по S3 через HttpClient Kora, а обертка AWS передает SDK SdkHttpClient поверх Kora. Подойдет любой транспорт — в примере на Java используется http-client-ok, в примере на Kotlin http-client-jdk, — но один из них должен быть в графе.

Модули

Теперь подключим оба модуля S3 к существующему HTTP-приложению.

Обновите src/main/java/io/koraframework/guide/s3/Application.java:

package io.koraframework.guide.s3;

import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.client.ok.OkHttpClientModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.s3.client.aws.AwsS3ClientModule;
import io.koraframework.s3.client.kora.KoraS3ClientModule;

@KoraApp
public interface Application extends
        HoconConfigModule,
        JsonModule,
        LogbackModule,
        OkHttpClientModule,
        AwsS3ClientModule,   // <----- Connected module
        KoraS3ClientModule,  // <----- Connected module
        UndertowPublicHttpServerModule {

    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}

Обновите src/main/kotlin/io/koraframework/guide/s3/Application.kt:

package io.koraframework.guide.s3

import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.client.jdk.JdkHttpClientModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.s3.client.aws.AwsS3ClientModule
import io.koraframework.s3.client.kora.KoraS3ClientModule

@KoraApp
interface Application :
    HoconConfigModule,
    JsonModule,
    LogbackModule,
    JdkHttpClientModule,
    AwsS3ClientModule,   // <----- Connected module
    KoraS3ClientModule,  // <----- Connected module
    UndertowPublicHttpServerModule

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}

AwsS3ClientModule публикует S3Client, привязанный к фиксированному пути конфигурации s3client.aws. KoraS3ClientModule публикует только инфраструктуру телеметрии и учетных данных, нужную сгенерированным декларативным клиентам, — каждый интерфейс @S3.Client приносит свой путь конфигурации, поэтому у самого модуля нет ни корзины, ни адреса.

Мы сохраняем те же модули HTTP-сервера из предыдущего руководства и добавляем только специфичные для S3 части.

Конфигурация

Приложение по-прежнему использует ту же конфигурацию HTTP-сервера из предыдущего руководства. Здесь мы добавляем две независимые секции, по одной на клиент.

Полный справочник по конфигурации смотрите в S3-клиенте.

src/main/resources/application.conf
s3client.aws { //(1)!
  url = ${S3_URL} //(2)!
  region = "us-east-1"
  region = ${?S3_REGION}

  credentials { //(3)!
    accessKey = ${S3_ACCESS_KEY}
    secretKey = ${S3_SECRET_KEY}
  }
}

s3client.uploads { //(4)!
  endpoint = ${S3_URL} //(5)!
  region = "us-east-1"
  region = ${?S3_REGION}

  bucket = "uploads" //(6)!
  bucket = ${?S3_BUCKET}

  credentials {
    accessKey = ${S3_ACCESS_KEY}
    secretKey = ${S3_SECRET_KEY}
  }
}
  1. Фиксированный путь для s3-client-aws. AwsS3ClientModule всегда читает именно его.
  2. Клиент AWS называет ключ адреса url.
  3. Оба клиента вкладывают учетные данные в собственный блок credentials.
  4. Свободный путь для декларативного клиента, выбранный его аннотацией @S3.Client.
  5. Декларативный клиент называет то же самое endpoint.
  6. Имя корзины, читаемое через @S3.Bucket(".bucket") и стартовым инициализатором.
src/main/resources/application.yaml
s3client:
  aws: #(1)!
    url: ${S3_URL} #(2)!
    region: ${?S3_REGION:"us-east-1"}
    credentials: #(3)!
      accessKey: ${S3_ACCESS_KEY}
      secretKey: ${S3_SECRET_KEY}
  uploads: #(4)!
    endpoint: ${S3_URL} #(5)!
    region: ${?S3_REGION:"us-east-1"}
    bucket: ${?S3_BUCKET:"uploads"} #(6)!
    credentials:
      accessKey: ${S3_ACCESS_KEY}
      secretKey: ${S3_SECRET_KEY}
  1. Фиксированный путь для s3-client-aws. AwsS3ClientModule всегда читает именно его.
  2. Клиент AWS называет ключ адреса url.
  3. Оба клиента вкладывают учетные данные в собственный блок credentials.
  4. Свободный путь для декларативного клиента, выбранный его аннотацией @S3.Client.
  5. Декларативный клиент называет то же самое endpoint.
  6. Имя корзины, читаемое через @S3.Bucket(".bucket") и стартовым инициализатором.

На трех деталях легко споткнуться.

Секции не вложены друг в друга и ничего не разделяют. s3client.aws зашит в AwsS3ClientModule; s3client.uploads существует только потому, что его называет аннотация @S3.Client на следующем шаге. Вы точно так же могли назвать его storage.files — единственный источник истины здесь аннотация.

Ключ адреса у них разный: url у клиента AWS и endpoint у декларативного. Ошибка здесь дает падение на старте с указанием отсутствующего ключа, что и есть самый быстрый способ обнаружить несоответствие.

bucket принадлежит только секции декларативного клиента. У клиента AWS вообще нет настройки корзины, потому что в AWS SDK корзина — аргумент каждого вызова.

Обе секции также принимают addressStyle (по умолчанию PATH, что и нужно MinIO) и requestTimeout, а декларативная добавляет блок upload, управляющий partSize, chunkSize и singlePartUploadLimit для составных загрузок.

Декларативный S3-клиент

Декларативный S3-клиент Kora работает в том же духе, что и поддержка HTTP-клиентов. Это главное понятие, которому учит руководство.

Создайте src/main/java/io/koraframework/guide/s3/s3/S3FileClient.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.s3.client.kora.annotation.S3;
import io.koraframework.s3.client.kora.model.response.GetObjectResult;
import io.koraframework.s3.client.kora.model.response.ListBucketResult;

@S3.Client("s3client.uploads")
@S3.Bucket(".bucket")
public interface S3FileClient {

    @S3.Put("files/{fileId}")
    String uploadFile(String fileId, byte[] body);

    @S3.Get("files/{fileId}")
    GetObjectResult downloadFile(String fileId);

    @S3.List("files/")
    ListBucketResult listFiles();

    @S3.Delete("files/{fileId}")
    void deleteFile(String fileId);
}

Создайте src/main/kotlin/io/koraframework/guide/s3/s3/S3FileClient.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.s3.client.kora.annotation.S3
import io.koraframework.s3.client.kora.model.response.GetObjectResult
import io.koraframework.s3.client.kora.model.response.ListBucketResult

@S3.Client("s3client.uploads")
@S3.Bucket(".bucket")
interface S3FileClient {

    @S3.Put("files/{fileId}")
    fun uploadFile(fileId: String, body: ByteArray): String

    @S3.Get("files/{fileId}")
    fun downloadFile(fileId: String): GetObjectResult

    @S3.List("files/")
    fun listFiles(): ListBucketResult

    @S3.Delete("files/{fileId}")
    fun deleteFile(fileId: String)
}

Этот интерфейс намеренно небольшой. Каждый метод почти один в один отображается на операцию хранилища, а аннотации задают ключ объекта или префикс ключа.

Несколько важных деталей:

  • @S3.Client("s3client.uploads") называет путь конфигурации, который читает этот клиент
  • @S3.Bucket(".bucket") называет ключ конфигурации с корзиной. Ведущая точка делает путь относительным пути самого клиента, так что он разворачивается в s3client.uploads.bucket. Абсолютный путь вроде @S3.Bucket("storage.files.bucket") тоже работает, а @S3.Bucket может вместо этого стоять на параметре метода, когда корзина выбирается во время выполнения
  • @S3.Put("files/{fileId}") строит итоговый ключ объекта из аргумента метода и возвращает ETag объекта как String
  • @S3.Get возвращает GetObjectResult — это HttpClientResponse: он несет заголовки и тело и должен быть закрыт
  • @S3.List("files/") ограничивает перечисление одним префиксом, что делает пример детерминированным, и возвращает запись ListBucketResult со списком items()

Параметр тела принимает byte[], ByteBuffer или InputStream. Массивы байтов подходят для небольших загрузок из этого руководства; поток — правильный выбор, когда файлы становятся достаточно большими, чтобы буферизовать их целиком было проблемой, а клиент сам переключается на составную загрузку после upload.singlePartUploadLimit.

Компиляция генерирует рядом с вашим интерфейсом три исходника: $S3FileClient_BucketsConfig читает имя корзины, $S3FileClient_S3ClientImpl реализует операции, а $S3FileClient_S3Module публикует клиент в графе.

Другие приемы работы с аннотациями — шаблоны ключей, ответы только с метаданными, диапазоны байтов и итераторы списков — смотрите в документации по S3-клиенту.

Инициализация корзины

Декларативный клиент умеет класть, получать, перечислять и удалять объекты, но не умеет создавать корзину, которая их держит: администрирование корзин не входит в контракт @S3. Ровно этот пробел и закрывает клиент AWS SDK.

Сначала откройте имя корзины собственному коду. @S3.Bucket порождает сгенерированный класс, а не внедряемый компонент, поэтому инициализатор читает тот же путь конфигурации сам:

Создайте src/main/java/io/koraframework/guide/s3/s3/S3UploadsConfig.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.config.common.annotation.ConfigSource;

@ConfigSource("s3client.uploads")
public interface S3UploadsConfig {

    String bucket();
}

Создайте src/main/kotlin/io/koraframework/guide/s3/s3/S3UploadsConfig.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.config.common.annotation.ConfigSource

@ConfigSource("s3client.uploads")
interface S3UploadsConfig {
    fun bucket(): String
}

Затем создайте корзину на старте, используя S3Client, опубликованный артефактом s3-client-aws:

Создайте src/main/java/io/koraframework/guide/s3/s3/S3BucketInitializer.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.application.graph.Lifecycle;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Root;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;

@Root
@Component
public final class S3BucketInitializer implements Lifecycle {

    private final S3Client s3Client;
    private final S3UploadsConfig config;

    public S3BucketInitializer(S3Client s3Client, S3UploadsConfig config) {
        this.s3Client = s3Client;
        this.config = config;
    }

    @Override
    public void init() {
        var bucket = this.config.bucket();
        try {
            this.s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
        } catch (NoSuchBucketException e) {
            this.s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
        }
    }

    @Override
    public void release() {}
}

Создайте src/main/kotlin/io/koraframework/guide/s3/s3/S3BucketInitializer.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.application.graph.Lifecycle
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Root
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.CreateBucketRequest
import software.amazon.awssdk.services.s3.model.HeadBucketRequest
import software.amazon.awssdk.services.s3.model.NoSuchBucketException

@Root
@Component
class S3BucketInitializer(
    private val s3Client: S3Client,
    private val config: S3UploadsConfig
) : Lifecycle {

    override fun init() {
        val bucket = config.bucket()
        try {
            s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build())
        } catch (e: NoSuchBucketException) {
            s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build())
        }
    }

    override fun release() {}
}

@Root здесь не опционален

Kora собирает только ту часть графа, от которой что-то действительно зависит. S3BucketInitializer никто не внедряет, поэтому без @Root он отсекается при построении графа, init() никогда не выполняется, и первая же загрузка падает на корзине, которую так и не создали. @Root — это то, что велит Kora создать компонент ради одного лишь побочного эффекта. Это общее правило для компонентов, существующих только ради жизненного цикла, а не особенность S3.

Тот же прием, описанный с другой стороны, — в разделе Администрирование корзин.

DTO метаданных

Модуль S3 уже дает низкоуровневые ответы хранилища, но наш HTTP API должен отдавать стабильное, удобное для руководства DTO.

Создайте src/main/java/io/koraframework/guide/s3/s3/FileMetadata.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.json.common.annotation.Json;

@Json
public record FileMetadata(String fileId, Long size, String contentType) {}

Создайте src/main/kotlin/io/koraframework/guide/s3/s3/FileMetadata.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.json.common.annotation.Json

@Json
data class FileMetadata(
    val fileId: String,
    val size: Long?,
    val contentType: String?
)

Мы отдаем только те поля, которые реально используем в руководстве:

  • fileId для публичного контракта API
  • size и contentType для просмотра сведений о файле

Контроллер метаданных

Теперь начинаем подключать декларативный S3-клиент к HTTP API.

На этом первом шаге контроллера мы реализуем операцию загрузки, которая начинает жизненный цикл файла в объектном хранилище.

Это удобная отправная точка, потому что она показывает главное разделение ответственности в дизайне:

  • контроллер понимает детали HTTP вроде FormMultipart
  • S3-клиент понимает ключи хранилища и операции над объектами

Такое разделение оставляет контроллер сосредоточенным на разборе запроса и формировании ответа, а декларативный S3-клиент — на объектном хранилище.

Обновите src/main/java/io/koraframework/guide/s3/controller/DataController.java:

package io.koraframework.guide.s3.controller;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.s3.s3.FileMetadata;
import io.koraframework.guide.s3.s3.S3FileClient;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.common.form.FormMultipart;
import io.koraframework.http.common.header.HttpHeaders;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.json.common.annotation.Json;
import io.koraframework.s3.client.kora.exception.S3ClientNoSuchKeyException;

@Component
@HttpController
public final class DataController {

    private final S3FileClient s3FileClient;

    public DataController(S3FileClient s3FileClient) {
        this.s3FileClient = s3FileClient;
    }

    @HttpRoute(method = HttpMethod.POST, path = "/files/upload")
    @Json
    public FileMetadata uploadFile(FormMultipart multipart) {
        var filePart = multipart.parts().stream()
                .filter(part -> "file".equals(part.name()))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("No file part named 'file' provided"));

        if (filePart instanceof FormMultipart.FormPart.MultipartFile mf) {
            return this.upload(mf.contentType(), mf.content());
        }
        // a streamed part carries an HttpBodyOutput, which knows how to write itself out
        if (filePart instanceof FormMultipart.FormPart.MultipartFileStream mfs) {
            var buffer = new ByteArrayOutputStream();
            try {
                mfs.content().write(buffer);
            } catch (IOException e) {
                throw HttpServerResponseException.of(400, "Failed to read uploaded file");
            }
            return this.upload(mfs.content().contentType(), buffer.toByteArray());
        }

        throw new IllegalArgumentException("Part 'file' must be a multipart file");
    }

    private FileMetadata upload(String contentType, byte[] body) {
        String actualContentType = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType;
        String fileId = UUID.randomUUID().toString();
        this.s3FileClient.uploadFile(fileId, body);
        return new FileMetadata(fileId, (long) body.length, actualContentType);
    }

    private FileMetadata toMetadata(String key, Long size, String contentType) {
        String normalized = key.startsWith("files/") ? key.substring("files/".length()) : key;
        return new FileMetadata(normalized, size, contentType);
    }

    @Json
    public record DeleteFileResponse(String message) {}
}

Обновите src/main/kotlin/io/koraframework/guide/s3/controller/DataController.kt:

package io.koraframework.guide.s3.controller

import io.koraframework.common.annotation.Component
import io.koraframework.guide.s3.s3.FileMetadata
import io.koraframework.guide.s3.s3.S3FileClient
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.common.form.FormMultipart
import io.koraframework.http.common.header.HttpHeaders
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import io.koraframework.s3.client.kora.exception.S3ClientNoSuchKeyException
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID

@Component
@HttpController
class DataController(
    private val s3FileClient: S3FileClient
) {

    @HttpRoute(method = HttpMethod.POST, path = "/files/upload")
    @Json
    fun uploadFile(multipart: FormMultipart): FileMetadata {
        val filePart = multipart.parts()
            .firstOrNull { it.name() == "file" }
            ?: throw IllegalArgumentException("No file part named 'file' provided")

        return when (filePart) {
            is FormMultipart.FormPart.MultipartFile -> upload(filePart.contentType(), filePart.content())

            // a streamed part carries an HttpBodyOutput, which knows how to write itself out
            is FormMultipart.FormPart.MultipartFileStream -> {
                val buffer = ByteArrayOutputStream()
                try {
                    filePart.content().write(buffer)
                } catch (e: IOException) {
                    throw HttpServerResponseException.of(400, "Failed to read uploaded file")
                }
                upload(filePart.content().contentType(), buffer.toByteArray())
            }

            else -> throw IllegalArgumentException("Part 'file' must be a multipart file")
        }
    }

    private fun upload(contentType: String?, body: ByteArray): FileMetadata {
        val actualContentType = if (contentType.isNullOrBlank()) "application/octet-stream" else contentType
        val fileId = UUID.randomUUID().toString()
        s3FileClient.uploadFile(fileId, body)
        return FileMetadata(fileId, body.size.toLong(), actualContentType)
    }

    private fun toMetadata(key: String, size: Long?, contentType: String?): FileMetadata {
        val normalized = key.removePrefix("files/")
        return FileMetadata(normalized, size, contentType)
    }

    @Json
    data class DeleteFileResponse(val message: String)
}

Этот контроллер держит пример честным:

  • загрузка использует FormMultipart, потому что это самая распространенная форма HTTP-загрузки файлов
  • ключи хранилища остаются внутренним делом контроллера и S3-клиента
  • публичный API отдает только fileId, а не подробности корзины и не пути, заданные пользователем

Multipart-части приходят в одной из двух форм. MultipartFile уже держит байты; MultipartFileStream держит HttpBodyOutput, который записывается в предоставленный вами OutputStream. Это руководство буферизует потоковый случай в памяти, чтобы пример остался коротким, — верное решение для небольших загрузок и неверное для больших: настоящий сервис направлял бы этот поток прямо в S3-клиент.

Список, скачивание и удаление

Когда загрузка на месте, можно добавить остальной жизненный цикл файла: чтение того, что сохранено, скачивание содержимого и удаление файла, когда он больше не нужен.

Эти эндпоинты закрывают оставшиеся части API — чтение и очистку:

  • GET /files позволяет клиентам посмотреть, что уже сохранено
  • GET /files/{fileId} превращает объект S3 в обычный HTTP-ответ на скачивание
  • DELETE /files/{fileId} удаляет объект, когда приложению он больше не нужен

Этот шаг полезен тем, что показывает, зачем контроллер нужен даже при декларативном хранилище. S3-клиент возвращает объекты, ориентированные на хранилище, а контроллер отвечает за:

  • отображение результатов списка на публичное DTO, которое мы хотим отдавать
  • превращение отсутствующих объектов в чистый HTTP 404
  • построение обычного скачиваемого HTTP-ответа с типом содержимого и заголовками
  • согласование запросов на удаление через тот же публичный контракт fileId

Добавьте оставшиеся эндпоинты в src/main/java/io/koraframework/guide/s3/controller/DataController.java:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    public List<FileMetadata> listFiles() {
        return this.s3FileClient.listFiles().items().stream()
                .map(item -> this.toMetadata(item.key(), item.size(), null))
                .toList();
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    public HttpServerResponse downloadFile(String fileId) {
        try (var object = this.s3FileClient.downloadFile(fileId); var body = object.body().asInputStream()) {
            var bytes = body.readAllBytes();
            var contentType = object.headers().getFirst("Content-Type");
            return HttpServerResponse.of(
                    200,
                    HttpHeaders.of("Content-Disposition", "attachment; filename=\"" + fileId + "\""),
                    HttpBody.of(contentType == null ? "application/octet-stream" : contentType, bytes));
        } catch (S3ClientNoSuchKeyException e) {
            throw HttpServerResponseException.of(404, "File not found");
        } catch (IOException e) {
            throw HttpServerResponseException.of(500, "Failed to read file");
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
    @Json
    public DeleteFileResponse deleteFile(String fileId) {
        this.s3FileClient.deleteFile(fileId);
        return new DeleteFileResponse("File deleted successfully");
    }

Добавьте оставшиеся эндпоинты в src/main/kotlin/io/koraframework/guide/s3/controller/DataController.kt:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    fun listFiles(): List<FileMetadata> {
        return s3FileClient.listFiles().items()
            .map { toMetadata(it.key(), it.size(), null) }
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    fun downloadFile(fileId: String): HttpServerResponse {
        try {
            s3FileClient.downloadFile(fileId).use { obj ->
                obj.body().asInputStream().use { body ->
                    val bytes = body.readAllBytes()
                    val contentType = obj.headers().getFirst("Content-Type") ?: "application/octet-stream"
                    return HttpServerResponse.of(
                        200,
                        HttpHeaders.of("Content-Disposition", "attachment; filename=\"$fileId\""),
                        HttpBody.of(contentType, bytes)
                    )
                }
            }
        } catch (e: S3ClientNoSuchKeyException) {
            throw HttpServerResponseException.of(404, "File not found")
        } catch (e: IOException) {
            throw HttpServerResponseException.of(500, "Failed to read file")
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
    @Json
    fun deleteFile(fileId: String): DeleteFileResponse {
        s3FileClient.deleteFile(fileId)
        return DeleteFileResponse("File deleted successfully")
    }

Главная мысль здесь в том, что этот второй шаг завершает публичный жизненный цикл файла. Список переводит низкоуровневые объекты хранилища в ваш публичный контракт FileMetadata, скачивание переводит объект S3 в настоящий HTTP-ответ с файлом, а удаление дает API чистый способ убрать сохраненное содержимое по fileId.

Две вещи в пути скачивания заслуживают внимания. GetObjectResult — это HttpClientResponse, то есть он держит живое соединение и должен быть закрыт, отсюда try-with-resources в Java и use в Kotlin. А отсутствующий ключ проявляется как S3ClientNoSuchKeyException, который контроллер и превращает в 404; удаление отсутствующего ключа не выбрасывает ничего, что соответствует семантике самого S3.

Docker Compose

Приложение говорит по S3, но для локальной разработки нам все равно нужен S3-совместимый сервер. MinIO для этого идеален.

Создайте docker-compose.yml в каталоге модуля приложения:

services:
    minio:
        image: minio/minio:latest
        ports:
            - "9000:9000"
            - "9001:9001"
        environment:
            MINIO_ROOT_USER: minioadmin
            MINIO_ROOT_PASSWORD: minioadmin
        command: server /data --console-address ":9001"

Запустите его:

docker compose up -d

Затем запустите приложение с переменными окружения:

S3_URL=http://localhost:9000 \
S3_ACCESS_KEY=minioadmin \
S3_SECRET_KEY=minioadmin \
S3_BUCKET=uploads \
./gradlew run

В Windows PowerShell:

$env:S3_URL = 'http://localhost:9000'
$env:S3_ACCESS_KEY = 'minioadmin'
$env:S3_SECRET_KEY = 'minioadmin'
$env:S3_BUCKET = 'uploads'
./gradlew run

Один S3_URL питает обе секции конфигурации, потому что оба клиента смотрят в одно и то же хранилище. Создавать корзину вручную не нужно: S3BucketInitializer делает это на старте.

Запуск приложения

Сначала скомпилируйте:

./gradlew clean classes

Затем запустите приложение с теми же переменными окружения S3, что показаны выше.

Проверить API можно, например, так:

curl -F "file=@./example.txt" http://localhost:8080/files/upload
curl http://localhost:8080/files
curl http://localhost:8080/files/<fileId>
curl -X DELETE http://localhost:8080/files/<fileId>

Запуск тестов

Тесты этого руководства не зависят от вручную запущенного MinIO. Они используют Testcontainer с MinIO и автоматически прокидывают параметры подключения в граф приложения Kora через KoraAppTestConfigModifier, который задает S3_URL, S3_ACCESS_KEY, S3_SECRET_KEY и S3_BUCKET как системные свойства из запущенного контейнера.

build.gradle
dependencies {
    testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
    testImplementation("io.goodforgod:testcontainers-extensions-minio:0.15.0")
    testImplementation("io.koraframework:test-junit5")
}

Запустите их обычным для руководств способом:

./gradlew test

Эта тестовая обвязка проверяет две вещи:

  • декларативный S3FileClient умеет загружать, скачивать и удалять объекты
  • расширенный DataController отдает ожидаемое поведение на уровне HTTP поверх S3-клиента

Лучшие практики

  • Используйте декларативный клиент для пути запроса, а к клиенту AWS SDK обращайтесь только там, куда контракт @S3 действительно не дотягивается: администрирование корзин, копирование, presigned URL.
  • Держите декларативный S3-клиент сфокусированным на одной корзине и одной ясной стратегии ключей.
  • Отдавайте стабильные HTTP-идентификаторы вроде fileId, а не протаскивайте сырые ключи объектов в каждый маршрут API.
  • Предпочитайте относительный @S3.Bucket(".bucket"), чтобы имя корзины оставалось внутри собственной секции конфигурации клиента и переезжало вместе с ней.
  • Помечайте компоненты, живущие только ради жизненного цикла, вроде инициализатора корзины, аннотацией @Root, иначе граф их отсечет.
  • Передавайте поток, а не массив байтов, для больших загрузок и позвольте upload.singlePartUploadLimit клиента решать, когда начинается составная загрузка.
  • Всегда закрывайте GetObjectResult: он держит живой HTTP-ответ, а не отсоединенный буфер.
  • Используйте MinIO локально и в тестах, но держите ключи конфигурации идентичными промышленным, чтобы менялись только адрес и учетные данные.

Итоги

В этом руководстве вы расширили HTTP-приложение файловым хранилищем поверх S3.

Вы добавили:

  • оба артефакта S3 и модуль HTTP-клиента, на котором они работают
  • отдельные секции конфигурации s3client.aws и s3client.uploads
  • декларативный S3FileClient, привязанный к корзине uploads
  • стартовый компонент с @Root, который через клиент AWS SDK гарантирует существование корзины
  • эндпоинты загрузки, списка, скачивания и удаления файлов в DataController
  • тесты поверх MinIO для потока S3

Главный урок в том, что декларативный S3-клиент Kora особенно хорош, когда контракт хранилища прост и стабилен, клиент AWS SDK покрывает административные края, а окружающий контроллер остается ответственным за специфичные для HTTP задачи вроде разбора multipart и ответов на скачивание.

Ключевые понятия

  • Два независимых артефакта: io.koraframework.experimental:s3-client-kora для декларативных клиентов и io.koraframework:s3-client-aws для S3Client из AWS SDK. Друг от друга они не зависят, а использовать их вместе — нормальная форма.
  • Декларативные S3-клиенты отображают операции хранилища через @S3.Client, @S3.Bucket, @S3.Put, @S3.Get, @S3.List и @S3.Delete.
  • Конфигурация задается на клиент: s3client.aws фиксирован и использует url, а путь декларативного клиента приходит из его аннотации и использует endpoint плюс bucket.
  • Обоим клиентам нужен модуль HTTP-клиента в графе; ни один из них не открывает собственных сокетов.
  • @Root сохраняет компонент, живущий только ради жизненного цикла, когда от него ничто в графе не зависит.
  • MinIO — это сервер, а не клиентская библиотека: Kora 2.0 не поставляет специфичного для MinIO клиентского артефакта, и ни одному артефакту S3 он не нужен.

Устранение неполадок

./gradlew clean падает из-за заблокированных файлов:

Остановите демоны Gradle и повторите:

./gradlew --stop
./gradlew clean classes

Windows AccessDeniedException в кеше Gradle:

Обычно это значит, что демон или другой процесс Java еще держит файлы в кеше Gradle. Сначала остановите демоны, затем повторите команду.

./gradlew --stop
./gradlew test

Не разрешается io.koraframework:s3-client-kora:

Декларативный клиент живет в экспериментальной группе. Используйте io.koraframework.experimental:s3-client-kora. Обертка AWS, наоборот, — обычный io.koraframework:s3-client-aws.

Не разрешается s3-client-minio:

Такого артефакта в Kora 2.0 нет, и он никогда не нужен. Используйте любой из артефактов S3 и направьте его адрес на ваш сервер MinIO.

Старт падает из-за отсутствующего значения конфигурации:

Проверьте, какой ключ хочет упавший клиент. s3client.aws требует url; декларативный клиент требует endpoint. Они не взаимозаменяемы, а ключ корзины принадлежит только декларативной секции.

Сборка графа падает, потому что нет доступного HttpClient:

Добавьте модуль HTTP-клиента, например OkHttpClientModule или JdkHttpClientModule. Он нужен обоим артефактам S3.

Приложение не может подключиться к MinIO:

Проверьте, что:

  • MinIO работает на http://localhost:9000
  • заданы S3_URL, S3_ACCESS_KEY и S3_SECRET_KEY
  • имя корзины в S3_BUCKET совпадает с конфигурацией из руководства
  • addressStyle оставлен со значением по умолчанию PATH, которого и ждет MinIO

Загрузки падают, потому что корзина не существует:

Убедитесь, что S3BucketInitializer помечен @Root. Без этого компонент отсекается из графа и init() никогда не выполняется.

GET /files/{fileId} возвращает 404:

Это значит, что ключа объекта files/{fileId} нет в настроенной корзине. Чаще всего так бывает потому, что:

  • объект был удален раньше
  • приложение смотрит на другую корзину или другой экземпляр MinIO
  • запрос на загрузку так и не завершился успешно

Docker или Testcontainers не могут запустить MinIO:

Убедитесь, что Docker запущен и доступен вашему пользователю. Если тесты в контейнерах падают, посмотрите логи Docker и проверьте, что порты 9000 и 9001 свободны для ручных запусков.

Что дальше?

  • Наблюдаемость, чтобы добавить метрики, трассировки, логи и пробы вокруг файловых операций.
  • HTTP-клиент, чтобы вызывать файловые эндпоинты из другого сервиса Kora.
  • Шаблоны отказоустойчивости, чтобы защитить вызовы хранилища от медленных или нестабильных зависимостей.
  • База данных JDBC перед черноящичным тестированием, если нужен сквозной путь тестов поверх JDBC.

Помощь

Если что-то не сходится: