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

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

Загрузка и хранение файлов с 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
  • локальную разработку и тесты на MinIO как S3-совместимой серверной части

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

database-jdbc-advanced.md - JDK 17 или новее - Gradle 7+ - 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-стиль хранилища для файлов:

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

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

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

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

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

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

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

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

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

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

  1. добавить модуль S3-клиента Kora и его зависимость HTTP-клиента
  2. настроить один декларативный S3-клиент для корзины uploads
  3. сопоставить multipart-запросы загрузки с записью объектов
  4. сопоставить маршруты скачивания с чтением объектов
  5. проверить то же поведение на MinIO в тестах

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

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

Зависимости

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

build.gradle
dependencies {
    implementation("ru.tinkoff.kora:http-client-ok")
    implementation("ru.tinkoff.kora.experimental:s3-client-aws")
}
build.gradle.kts
dependencies {
    implementation("ru.tinkoff.kora:http-client-ok")
    implementation("ru.tinkoff.kora.experimental:s3-client-aws")
}

http-client-ok нужен, потому что AWS S3-модулю под капотом требуется реализация HTTP-клиента.

Модули

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

Обновите src/main/java/ru/tinkoff/kora/guide/s3/Application.java:

package ru.tinkoff.kora.guide.s3;

import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.http.client.ok.OkHttpClientModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
import ru.tinkoff.kora.s3.client.aws.AwsS3ClientModule;

@KoraApp
public interface Application extends
        HoconConfigModule,
        JsonModule,
        LogbackModule,
        OkHttpClientModule,
        AwsS3ClientModule,  // <----- Подключили модуль
        UndertowHttpServerModule {

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

Обновите src/main/kotlin/ru/tinkoff/kora/guide/s3/Application.kt:

package ru.tinkoff.kora.guide.s3

import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.http.client.ok.OkHttpClientModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
import ru.tinkoff.kora.s3.client.aws.AwsS3ClientModule

@KoraApp
interface Application :
    HoconConfigModule,
    JsonModule,
    LogbackModule,
    OkHttpClientModule,
    AwsS3ClientModule,  // <----- Подключили модуль
    UndertowHttpServerModule

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

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

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

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

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

src/main/resources/application.conf
s3client {
  url = ${S3_URL} //(1)!
  accessKey = ${S3_ACCESS_KEY} //(2)!
  secretKey = ${S3_SECRET_KEY} //(3)!
  region = "us-east-1" //(4)!
  region = ${?S3_REGION} //(5)!

  uploads {
    bucket = "uploads" //(6)!
    bucket = ${?S3_BUCKET} //(7)!
  }
}
  1. Обязательный URL конечной точки S3 из S3_URL.
  2. Обязательный ключ доступа S3 из S3_ACCESS_KEY.
  3. Обязательный секретный ключ S3 из S3_SECRET_KEY.
  4. Регион S3 по умолчанию для локального MinIO.
  5. Необязательное переопределение региона из S3_REGION.
  6. Имя корзины по умолчанию для декларативного клиента uploads.
  7. Необязательное переопределение корзины из S3_BUCKET.
src/main/resources/application.yaml
s3client:
  url: ${S3_URL} #(1)!
  accessKey: ${S3_ACCESS_KEY} #(2)!
  secretKey: ${S3_SECRET_KEY} #(3)!
  region: ${?S3_REGION:"us-east-1"} #(4)!
  uploads:
    bucket: ${?S3_BUCKET:"uploads"} #(5)!
  1. Обязательный URL конечной точки S3 из S3_URL.
  2. Обязательный ключ доступа S3 из S3_ACCESS_KEY.
  3. Обязательный секретный ключ S3 из S3_SECRET_KEY.
  4. Регион S3 с локальным значением по умолчанию и необязательным переопределением S3_REGION.
  5. Корзина для загрузок с локальным значением по умолчанию и необязательным переопределением S3_BUCKET.

Эта конфигурация делает две вещи:

  • настраивает общее подключение AWS S3-клиента
  • настраивает один именованный декларативный клиент в s3client.uploads

Этот подраздел uploads важен, потому что на следующем шаге мы напрямую привяжем его к @S3.Client("s3client.uploads").

MinIO здесь все равно полезен, хотя мы используем реализацию AWS-клиента. MinIO говорит на S3 API, поэтому дает дешевое локальное окружение и при этом сохраняет код приложения согласованным с клиентами в стиле AWS.

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

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

Создайте src/main/java/ru/tinkoff/kora/guide/s3/s3/S3FileClient.java:

package ru.tinkoff.kora.guide.s3.s3;

import ru.tinkoff.kora.s3.client.annotation.S3;
import ru.tinkoff.kora.s3.client.model.S3Body;
import ru.tinkoff.kora.s3.client.model.S3Object;
import ru.tinkoff.kora.s3.client.model.S3ObjectList;
import ru.tinkoff.kora.s3.client.model.S3ObjectUpload;

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

    @S3.Put("files/{fileId}")
    S3ObjectUpload uploadFile(String fileId, S3Body body);

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

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

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

Создайте src/main/kotlin/ru/tinkoff/kora/guide/s3/s3/S3FileClient.kt:

package ru.tinkoff.kora.guide.s3.s3

import ru.tinkoff.kora.s3.client.annotation.S3
import ru.tinkoff.kora.s3.client.model.S3Body
import ru.tinkoff.kora.s3.client.model.S3Object
import ru.tinkoff.kora.s3.client.model.S3ObjectList
import ru.tinkoff.kora.s3.client.model.S3ObjectUpload

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

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

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

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

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

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

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

  • @S3.Put("files/{fileId}") строит итоговый объектный ключ из аргумента метода
  • @S3.List("files/") ограничивает вывод списка одним префиксом, что делает пример детерминированным
  • S3Body - это способ передать поток или материализованное содержимое загруженного файла в S3-клиент

Больше шаблонов аннотаций, например шаблоны, ответы только с метаданными и варианты списков, смотрите в документации S3-клиента.

DTO метаданных

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

Создайте src/main/java/ru/tinkoff/kora/guide/s3/s3/FileMetadata.java:

package ru.tinkoff.kora.guide.s3.s3;

import ru.tinkoff.kora.json.common.annotation.Json;

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

Создайте src/main/kotlin/ru/tinkoff/kora/guide/s3/s3/FileMetadata.kt:

package ru.tinkoff.kora.guide.s3.s3

import ru.tinkoff.kora.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/ru/tinkoff/kora/guide/s3/controller/DataController.java:

package ru.tinkoff.kora.guide.s3.controller;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.common.util.ByteBufferPublisherInputStream;
import ru.tinkoff.kora.guide.s3.s3.FileMetadata;
import ru.tinkoff.kora.guide.s3.s3.S3FileClient;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.body.HttpBody;
import ru.tinkoff.kora.http.common.form.FormMultipart;
import ru.tinkoff.kora.http.common.header.HttpHeaders;
import ru.tinkoff.kora.http.server.common.HttpServerResponse;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
import ru.tinkoff.kora.s3.client.S3NotFoundException;
import ru.tinkoff.kora.s3.client.model.S3Body;

@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.uploadStream(mf.fileName(), mf.contentType(), new ByteArrayInputStream(mf.content()));
        }
        if (filePart instanceof FormMultipart.FormPart.MultipartFileStream mfs) {
            return this.uploadStream(mfs.fileName(), mfs.contentType(), new ByteBufferPublisherInputStream(mfs.content()));
        }

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

    private FileMetadata uploadStream(String fileName, String contentType, InputStream inputStream) {
        String actualContentType = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType;
        String fileId = java.util.UUID.randomUUID().toString();
        S3Body body = S3Body.ofInputStreamReadAll(inputStream, actualContentType);
        this.s3FileClient.uploadFile(fileId, body);
        return new FileMetadata(fileId, body.size(), 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/ru/tinkoff/kora/guide/s3/controller/DataController.kt:

package ru.tinkoff.kora.guide.s3.controller

import java.io.ByteArrayInputStream
import java.io.InputStream
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.common.util.ByteBufferPublisherInputStream
import ru.tinkoff.kora.guide.s3.s3.FileMetadata
import ru.tinkoff.kora.guide.s3.s3.S3FileClient
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.body.HttpBody
import ru.tinkoff.kora.http.common.form.FormMultipart
import ru.tinkoff.kora.http.common.header.HttpHeaders
import ru.tinkoff.kora.http.server.common.HttpServerResponse
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
import ru.tinkoff.kora.s3.client.S3NotFoundException
import ru.tinkoff.kora.s3.client.model.S3Body

@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 -> {
                uploadStream(filePart.fileName(), filePart.contentType(), ByteArrayInputStream(filePart.content()))
            }
            is FormMultipart.FormPart.MultipartFileStream -> {
                uploadStream(filePart.fileName(), filePart.contentType(), ByteBufferPublisherInputStream(filePart.content()))
            }
            else -> throw IllegalArgumentException("Part 'file' must be a multipart file")
        }
    }

    private fun uploadStream(fileName: String?, contentType: String?, inputStream: InputStream): FileMetadata {
        val actualContentType = if (contentType.isNullOrBlank()) "application/octet-stream" else contentType
        val fileId = java.util.UUID.randomUUID().toString()
        val body = S3Body.ofInputStreamReadAll(inputStream, actualContentType)
        s3FileClient.uploadFile(fileId, body)
        return FileMetadata(fileId, body.size(), actualContentType)
    }

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

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

Этот контроллер сохраняет пример честным:

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

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

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

Эти конечные точки решают оставшиеся части API для чтения и очистки:

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

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

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

Добавьте оставшиеся конечные точки в src/main/java/ru/tinkoff/kora/guide/s3/controller/DataController.java:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    public java.util.List<FileMetadata> listFiles() {
        return this.s3FileClient.listFiles().objects().stream()
                .map(object -> this.toMetadata(object.key(), object.size(), object.body().type()))
                .toList();
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    public HttpServerResponse downloadFile(String fileId) {
        try {
            var object = this.s3FileClient.downloadFile(fileId);
            var bytes = object.body().asBytes();
            var contentType = object.body().type() == null ? "application/octet-stream" : object.body().type();
            return HttpServerResponse.of(
                    200,
                    HttpHeaders.of("Content-Disposition", "attachment; filename=\"" + fileId + "\""),
                    HttpBody.of(contentType, bytes));
        } catch (S3NotFoundException e) {
            throw HttpServerResponseException.of(404, "File not found");
        }
    }

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

Добавьте оставшиеся конечные точки в src/main/kotlin/ru/tinkoff/kora/guide/s3/controller/DataController.kt:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    fun listFiles(): List<FileMetadata> {
        return s3FileClient.listFiles().objects().map { object ->
            toMetadata(object.key(), object.size(), object.body().type())
        }
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    fun downloadFile(fileId: String): HttpServerResponse {
        return try {
            val objectResponse = s3FileClient.downloadFile(fileId)
            val bytes = objectResponse.body().asBytes()
            val contentType = objectResponse.body().type() ?: "application/octet-stream"
            HttpServerResponse.of(
                200,
                HttpHeaders.of("Content-Disposition", "attachment; filename=\"$fileId\""),
                HttpBody.of(contentType, bytes)
            )
        } catch (_: S3NotFoundException) {
            throw HttpServerResponseException.of(404, "File not found")
        }
    }

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

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

Docker Compose

Код приложения использует AWS 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

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

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

./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. Они используют MinIO Testcontainer и автоматически связывают значения подключения с графом приложения Kora.

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

./gradlew test

Эта тестовая настройка проверяет две вещи:

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

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

  • Держите декларативный S3-клиент сфокусированным на одной корзине и одной понятной стратегии ключей.
  • Раскрывайте стабильные HTTP-идентификаторы вроде fileId вместо того, чтобы протаскивать сырые объектные ключи в каждый маршрут API.
  • Используйте конструкторы S3Body, которые соответствуют форме ваших данных. Для очень больших потоков предпочитайте потоковые варианты, а не буферизацию целых файлов в памяти.
  • Используйте MinIO локально, но сохраняйте код приложения согласованным с AWS-клиентским модулем, если промышленная среда будет использовать инфраструктуру в стиле AWS.

Итоги

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

Вы добавили:

  • AWS S3-клиентский модуль и его зависимость HTTP-клиента
  • декларативный S3FileClient
  • стартовый компонент, который гарантирует существование корзины
  • конечные точки загрузки, списка, скачивания и удаления файлов в DataController
  • тесты S3-потока на основе MinIO

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

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

  • S3-совместимое хранилище позволяет разрабатывать локально с MinIO и при этом целиться в клиенты и инфраструктуру в стиле AWS.
  • Декларативные S3-клиенты сопоставляют операции хранилища с @S3.Client, @S3.Put, @S3.Get, @S3.List и @S3.Delete.
  • HTTP-задачи и задачи хранилища должны оставаться разделенными: контроллеры обрабатывают HTTP-договоры, а декларативные клиенты - договоры объектного хранилища.
  • Тесты на основе MinIO дают реалистичную проверку без вручную управляемого S3-окружения.

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

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

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

./gradlew --stop
./gradlew clean classes

Windows AccessDeniedException в кеше Gradle:

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

./gradlew --stop
./gradlew test

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

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

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

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

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

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

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

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

Что дальше?

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

Помощь

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