Загрузка и хранение файлов с 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.
Практический порядок такой:
- добавить оба артефакта S3 и HTTP-клиент, на котором они работают
- настроить клиент AWS по пути
s3client.awsи один декларативный клиент по путиs3client.uploads - объявить интерфейс
@S3.Clientи указать его@S3.Bucketна имя настроенной корзины - создать корзину на старте через клиент AWS SDK
- отобразить multipart-загрузки на запись объектов, а маршруты скачивания — на чтение объектов
- проверить то же поведение поверх MinIO в тестах
Локальный MinIO и промышленная форма¶
MinIO используется как локальная S3-совместимая инфраструктура, потому что его легко поднять для разработки и тестов. Код приложения остается ровно тем же и против настоящего S3: меняются только адрес и учетные данные. Тесты используют контейнеры, чтобы поведение хранилища было воспроизводимым.
Зависимости¶
Мы строим поверх существующего HTTP-приложения, поэтому добавляем два артефакта S3 и модуль HTTP-клиента, на котором работают оба.
Обратите внимание на 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-клиенте.
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}
}
}
- Фиксированный путь для
s3-client-aws.AwsS3ClientModuleвсегда читает именно его. - Клиент AWS называет ключ адреса
url. - Оба клиента вкладывают учетные данные в собственный блок
credentials. - Свободный путь для декларативного клиента, выбранный его аннотацией
@S3.Client. - Декларативный клиент называет то же самое
endpoint. - Имя корзины, читаемое через
@S3.Bucket(".bucket")и стартовым инициализатором.
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}
- Фиксированный путь для
s3-client-aws.AwsS3ClientModuleвсегда читает именно его. - Клиент AWS называет ключ адреса
url. - Оба клиента вкладывают учетные данные в собственный блок
credentials. - Свободный путь для декларативного клиента, выбранный его аннотацией
@S3.Client. - Декларативный клиент называет то же самое
endpoint. - Имя корзины, читаемое через
@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:
Затем создайте корзину на старте, используя 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:
Мы отдаем только те поля, которые реально используем в руководстве:
fileIdдля публичного контракта APIsizeи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"
Запустите его:
Затем запустите приложение с переменными окружения:
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 делает это на старте.
Запуск приложения¶
Сначала скомпилируйте:
Затем запустите приложение с теми же переменными окружения 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 как системные свойства из запущенного контейнера.
dependencies {
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
testImplementation("io.goodforgod:testcontainers-extensions-minio:0.15.0")
testImplementation("io.koraframework:test-junit5")
}
Запустите их обычным для руководств способом:
Эта тестовая обвязка проверяет две вещи:
- декларативный
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 и повторите:
Windows AccessDeniedException в кеше Gradle:
Обычно это значит, что демон или другой процесс Java еще держит файлы в кеше Gradle. Сначала остановите демоны, затем повторите команду.
Не разрешается 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.
Помощь¶
Если что-то не сходится:
- сравните с Kora Java S3 App и Kora Kotlin S3 App
- перечитайте HTTP-сервер продвинутый про обработку multipart-запросов
- посмотрите документацию по S3-клиенту
- посмотрите документацию по HTTP-серверу
- посмотрите документацию по конфигурации