Go + Minio: как написать простой сервер для взаимодействия с файлами

от автора

Добрейшего! Недавно мой друг решил хранить картинки продуктов на сервере, в отдельной папочке, выдав ей публичный доступ. Что ж, эту статью я пишу чтобы рассказать другу плюсы и минусы, а так же показать как можно делать иначе.

Если вы только изучаете go, начинаете писать сервера, то обязательно посмотрите эту статью — для бекендера уметь работать с s3 хранилищем так же важно, как и уметь работать с реляционной / нереляционной базой данных и с key-value базой — это основа основ.

одно из самых доступных s3 хранилищ конечно же minio

одно из самых доступных s3 хранилищ конечно же minio

Хранение файлов на сервере

Разберемся почему же, все-таки, файлики важно хранить именно в s3 хранилище, а не на сервере

  1. Безопасность данных:

    • Пример: Конфиденциальные документы (подпись об NDA) сотрудников находятся в публичной папке. Хакеры получают доступ и крадут личные данные, что приводит к утечке и юридическим последствиям.

  2. Уязвимость к атакам:

    • Пример: Публичные изображения товаров на сервере подвергаются DDoS-атаке, делая сайт недоступным и приводя к потере продаж.

  3. Отсутствие контроля версий и управления доступом:

    • Пример: Публичные изображения товаров хранятся в папке на сервере. В случае случайного удаления или изменения изображений нет возможности восстановить предыдущие версии. Это приводит к потере важных данных, чего можно избежать с использованием системы контроля версий в облачном хранилище, таком как S3.

  4. Нехватка масштабируемости:

    • Пример: Пользовательские данные хранятся на одном сервере. По мере роста данных сервер перегружается, замедляя работу приложения. Облачные решения, такие как S3, позволяют легко масштабировать хранилище.

  5. Управление резервными копиями и восстановлением:

    • Пример: Важные документы хранятся на локальном сервере. Аппаратный сбой приводит к потере данных, так как резервное копирование не проводилось. В облачном хранилище, таком как S3, резервные копии создаются автоматически.

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

В нашем примере мы будем использовать minio и вот почему:

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

Далее о том как это делается.

Интеграция S3 хранилища на языке Golang

Итак, что мы сделаем в этом туториале:

  • Напишем простой web server с использованием gin

  • Добавим библиотеку для работы с переменными окружения, напишем config файл, который можно будет переиспользовать

  • Напишем minio client — клиент, отвечающий за подключение и работу с S3 хранилищем minio

  • Напишем 6 методов взаимодействия с S3 хранилищем minio

    • CreateOne

    • CreateMany

    • GetOne

    • GetMany

    • DeleteOne

    • DeleteMany

  • Напишем 6 хендлеров для взаимодействия с методами

Так мы получим небольшой проект, который можно будет реиспользовать в других проектах, научимся работать с S3 на Go, а конкретно с Minio

  1. Создание структуры проекта, подготовка окружения:

    Тыкс — структура проекта. Можно просто вставить список команд и у вас появится такая же.

    minio-gin-crud/
    ├── cmd/
    │ └── main.go
    ├── internal/
    │ ├── common/
    │ │ ├── config/
    │ │ │ └── config.go
    │ │ ├── dto/
    │ │ │ └── minio.go
    │ │ └── errors/
    │ │ └── errors.go
    │ │ └── responses/
    │ │ └── responses.go
    │ ├── handler/
    │ │ ├── minio/
    │ │ │ ├── handler.go
    │ │ │ └── minio.go
    │ │ └── handler.go
    │ ├── service/
    │ │ ├── minio/
    │ │ │ ├── minio.go
    │ │ │ └── service.go
    │ │ └── service.go
    ├── pkg/
    │ └── helpers/
    │ │ └── create-response.go
    │ │ └── file-data.type.go
    │ │ └── operation.error.go
    │ └── minio_client.go
    │ └── minio_service.go
    ├── .env
    ├── README.md
    ├── docker-compose.yml
    └── go.mod

    mkdir minio-gin-crud cd minio-gin-crud  mkdir cmd mkdir internal mkdir pkg/ mkdir pkg/minio mkdir pkg/minio/helpers  mkdir internal/common mkdir internal/handler  mkdir internal/common/dto  mkdir internal/common/errors mkdir internal/common/responses mkdir internal/common/config  mkdir internal/handler/ mkdir internal/handler/minioHandler  touch cmd/main.go touch pkg/minio/minio_client.go touch pkg/minio/minio_service.go touch pkg/minio/helpers/operation.error.go touch pkg/minio/helpers/file-data.type.go  touch internal/handler/handler.go touch internal/handler/minio/handler.go touch internal/handler/minio/minio.go  touch internal/common/errors/errors.go touch internal/common/responses/responses.go touch internal/common/config/config.go touch internal/common/dto/minio.go  touch README.md touch docker-compose.yml touch .env  go mod init minio-gin-crud  go get github.com/gin-gonic/gin go get github.com/minio/minio-go/v7 go get github.com/joho/godotenv
  2. Docker-compose или как развернуть S3 хранилище

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

    version: '3'  services:   minio:     container_name: minio     image: 'bitnami/minio:latest'     volumes:       - 'minio_data:/data'     ports:       - "9000:9000"     restart: unless-stopped     environment:       MINIO_ROOT_USER: "${MINIO_ROOT_USER}"       MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"       MINIO_USE_SSL: "${MINIO_USE_SSL}"       MINIO_DEFAULT_BUCKETS: "${MINIO_BUCKET_NAME}"     healthcheck:       test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]       interval: 30s       timeout: 20s       retries: 3  volumes:   minio_data:

    Команды для запуска думаю вам знакома:
    docker-compose up —build
    Дальше круче: продолжаем

  3. Main server && Godotenv && Minio client

    Теперь то и начинается программирование.
    Первое с чего можно начать — это с подключения библиотеки godotenv — она, как уже говорилось ранее, нужна для простого взаимодействия с переменными окружения в Golang. Далее в комментариях будет описан каждый шаг:

    package config  import ( "github.com/joho/godotenv" "log" "os" "strconv" )  // Config структура, обозначающая структуру .env файла type Config struct { Port              string // Порт, на котором запускается сервер MinioEndpoint     string // Адрес конечной точки Minio BucketName        string // Название конкретного бакета в Minio MinioRootUser     string // Имя пользователя для доступа к Minio MinioRootPassword string // Пароль для доступа к Minio MinioUseSSL       bool   // Переменная, отвечающая за }  var AppConfig *Config  // LoadConfig загружает конфигурацию из файла .env func LoadConfig() { // Загружаем переменные окружения из файла .env err := godotenv.Load() if err != nil { log.Fatalf("Error loading .env file") }  // Устанавливаем конфигурационные параметры AppConfig = &Config{ Port: getEnv("PORT", "8080"),  MinioEndpoint:     getEnv("MINIO_ENDPOINT", "localhost:9000"), BucketName:        getEnv("MINIO_BUCKET_NAME", "defaultBucket"), MinioRootUser:     getEnv("MINIO_ROOT_USER", "root"), MinioRootPassword: getEnv("MINIO_ROOT_PASSWORD", "minio_password"), MinioUseSSL:       getEnvAsBool("MINIO_USE_SSL", false), } }  // getEnv считывает значение переменной окружения или возвращает значение по умолчанию, если переменная не установлена func getEnv(key string, defaultValue string) string { if value, exists := os.LookupEnv(key); exists { return value } return defaultValue }  // getEnvAsInt считывает значение переменной окружения как целое число или возвращает значение по умолчанию, если переменная не установлена или не может быть преобразована в целое число func getEnvAsInt(key string, defaultValue int) int { if valueStr := getEnv(key, ""); valueStr != "" { if value, err := strconv.Atoi(valueStr); err == nil { return value } } return defaultValue }  // getEnvAsBool считывает значение переменной окружения как булево или возвращает значение по умолчанию, если переменная не установлена или не может быть преобразована в булево func getEnvAsBool(key string, defaultValue bool) bool { if valueStr := getEnv(key, ""); valueStr != "" { if value, err := strconv.ParseBool(valueStr); err == nil { return value } } return defaultValue } 

    Теперь, когда у нас есть конфиг, мы можем создать main.go и minio_client.go.

    Давайте создадим minio_client.go в первую очередь, а так же интерфейс этого клиента со всеми сопутствующими методами:

    package minio  import ( "context" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "minio-gin-crud/internal/common/config" "minio-gin-crud/pkg/minio/helpers" )  // Client интерфейс для взаимодействия с Minio type Client interface { InitMinio() error                                             // Метод для инициализации подключения к Minio CreateOne(file helpers.FileDataType) (string, error)          // Метод для создания одного объекта в бакете Minio CreateMany(map[string]helpers.FileDataType) ([]string, error) // Метод для создания нескольких объектов в бакете Minio GetOne(objectID string) (string, error)                       // Метод для получения одного объекта из бакета Minio GetMany(objectIDs []string) ([]string, error)                 // Метод для получения нескольких объектов из бакета Minio DeleteOne(objectID string) error                              // Метод для удаления одного объекта из бакета Minio DeleteMany(objectIDs []string) error                          // Метод для удаления нескольких объектов из бакета Minio }  // minioClient реализация интерфейса MinioClient type minioClient struct { mc *minio.Client // Клиент Minio }  // NewMinioClient создает новый экземпляр Minio Client func NewMinioClient() Client { return &minioClient{} // Возвращает новый экземпляр minioClient с указанным именем бакета }  // InitMinio подключается к Minio и создает бакет, если не существует // Бакет - это контейнер для хранения объектов в Minio. Он представляет собой пространство имен, в котором можно хранить и организовывать файлы и папки. func (m *minioClient) InitMinio() error { // Создание контекста с возможностью отмены операции ctx := context.Background()  // Подключение к Minio с использованием имени пользователя и пароля client, err := minio.New(config.AppConfig.MinioEndpoint, &minio.Options{ Creds:  credentials.NewStaticV4(config.AppConfig.MinioRootUser, config.AppConfig.MinioRootPassword, ""), Secure: config.AppConfig.MinioUseSSL, }) if err != nil { return err }  // Установка подключения Minio m.mc = client  // Проверка наличия бакета и его создание, если не существует exists, err := m.mc.BucketExists(ctx, config.AppConfig.BucketName) if err != nil { return err } if !exists { err := m.mc.MakeBucket(ctx, config.AppConfig.BucketName, minio.MakeBucketOptions{}) if err != nil { return err } }  return nil } 

    Теперь осталось написать main.go — файл, который будет запускать весь проект:

    package main  import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" "log" "minio-gin-crud/internal/common/config" "minio-gin-crud/pkg/minio" )  func main() { // Загрузка конфигурации из файла .env err := godotenv.Load() if err != nil { log.Fatalf("Ошибка загрузки файла .env: %v", err) }  // Инициализация соединения с Minio minioClient := minio.NewMinioClient() err = minioClient.InitMinio() if err != nil { log.Fatalf("Ошибка инициализации Minio: %v", err) }  // Инициализация маршрутизатора Gin router := gin.Default()  // Запуск сервера Gin port := config.AppConfig.Port // Мы берем err = router.Run(":" + port) if err != nil { log.Fatalf("Ошибка запуска сервера Gin: %v", err) } } 

    У нас получился вполне простой main.go файл. Далее сюда нам надо будет добавить еще обработку хендлеров, но это позже. Теперь нам нужно описать интерфейс minio.Client — добавить в minio_service.go все недостающие методы, потому что до текущего момента проект нельзя будет запустить.

  4. Minio service

    Итак, тут му будем описывать все методы взаимодействия с Minio. Каждый метод будет прокомментирован, но, тем не менее, если останется что-то непонятное — пишите в комментарии я или кто-то другой обязательно вам помогут!

    1. CreateOne

      // CreateOne создает один объект в бакете Minio. // Метод принимает структуру fileData, которая содержит имя файла и его данные. // В случае успешной загрузки данных в бакет, метод возвращает nil, иначе возвращает ошибку. // Все операции выполняются в контексте задачи. func (m *minioClient) CreateOne(file helpers.FileDataType) (string, error) { // Генерация уникального идентификатора для нового объекта. objectID := uuid.New().String()  // Создание потока данных для загрузки в бакет Minio. reader := bytes.NewReader(file.Data)  // Загрузка данных в бакет Minio с использованием контекста для возможности отмены операции. _, err := m.mc.PutObject(context.Background(), config.AppConfig.BucketName, objectID, reader, int64(len(file.Data)), minio.PutObjectOptions{}) if err != nil { return "", fmt.Errorf("ошибка при создании объекта %s: %v", file.FileName, err) }  // Получение URL для загруженного объекта url, err := m.mc.PresignedGetObject(context.Background(), config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { return "", fmt.Errorf("ошибка при создании URL для объекта %s: %v", file.FileName, err) }  return url.String(), nil }
    2. CreateMany

      // CreateMany создает несколько объектов в хранилище MinIO из переданных данных. // Если происходит ошибка при создании объекта, метод возвращает ошибку, // указывающую на неудачные объекты. func (m *minioClient) CreateMany(data map[string]helpers.FileDataType) ([]string, error) { urls := make([]string, 0, len(data)) // Массив для хранения URL-адресов  ctx, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции. defer cancel()                                          // Отложенный вызов функции отмены контекста при завершении функции CreateMany.  // Создание канала для передачи URL-адресов с размером, равным количеству переданных данных. urlCh := make(chan string, len(data))  var wg sync.WaitGroup // WaitGroup для ожидания завершения всех горутин.  // Запуск горутин для создания каждого объекта. for objectID, file := range data { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины. go func(objectID string, file helpers.FileDataType) { defer wg.Done()                                                                                                                                   // Уменьшение счетчика WaitGroup после завершения горутины. _, err := m.mc.PutObject(ctx, config.AppConfig.BucketName, objectID, bytes.NewReader(file.Data), int64(len(file.Data)), minio.PutObjectOptions{}) // Создание объекта в бакете MinIO. if err != nil { cancel() // Отмена операции при возникновении ошибки. return }  // Получение URL для загруженного объекта url, err := m.mc.PresignedGetObject(ctx, config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { cancel() // Отмена операции при возникновении ошибки. return }  urlCh <- url.String() // Отправка URL-адреса в канал с URL-адресами. }(objectID, file) // Передача данных объекта в анонимную горутину. }  // Ожидание завершения всех горутин и закрытие канала с URL-адресами. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0. close(urlCh) // Закрытие канала с URL-адресами после завершения всех горутин. }()  // Сбор URL-адресов из канала. for url := range urlCh { urls = append(urls, url) // Добавление URL-адреса в массив URL-адресов. }  return urls, nil }
    3. GetOne

      // GetOne получает один объект из бакета Minio по его идентификатору. // Он принимает строку `objectID` в качестве параметра и возвращает срез байт данных объекта и ошибку, если такая возникает. func (m *minioClient) GetOne(objectID string) (string, error) { // Получение предварительно подписанного URL для доступа к объекту Minio. url, err := m.mc.PresignedGetObject(context.Background(), config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { return "", fmt.Errorf("ошибка при получении URL для объекта %s: %v", objectID, err) }  return url.String(), nil }
    4. GetMany

      // GetMany получает несколько объектов из бакета Minio по их идентификаторам. func (m *minioClient) GetMany(objectIDs []string) ([]string, error) { // Создание каналов для передачи URL-адресов объектов и ошибок urlCh := make(chan string, len(objectIDs))                 // Канал для URL-адресов объектов errCh := make(chan helpers.OperationError, len(objectIDs)) // Канал для ошибок  var wg sync.WaitGroup                                 // WaitGroup для ожидания завершения всех горутин _, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции defer cancel()                                        // Отложенный вызов функции отмены контекста при завершении функции GetMany  // Запуск горутин для получения URL-адресов каждого объекта. for _, objectID := range objectIDs { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины go func(objectID string) { defer wg.Done()                // Уменьшение счетчика WaitGroup после завершения горутины url, err := m.GetOne(objectID) // Получение URL-адреса объекта по его идентификатору с помощью метода GetOne if err != nil { errCh <- helpers.OperationError{ObjectID: objectID, Error: fmt.Errorf("ошибка при получении объекта %s: %v", objectID, err)} // Отправка ошибки в канал с ошибками cancel()                                                                                                                     // Отмена операции при возникновении ошибки return } urlCh <- url // Отправка URL-адреса объекта в канал с URL-адресами }(objectID) // Передача идентификатора объекта в анонимную горутину }  // Закрытие каналов после завершения всех горутин. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0 close(urlCh) // Закрытие канала с URL-адресами после завершения всех горутин close(errCh) // Закрытие канала с ошибками после завершения всех горутин }()  // Сбор URL-адресов объектов и ошибок из каналов. var urls []string // Массив для хранения URL-адресов var errs []error  // Массив для хранения ошибок for url := range urlCh { urls = append(urls, url) // Добавление URL-адреса в массив URL-адресов } for opErr := range errCh { errs = append(errs, opErr.Error) // Добавление ошибки в массив ошибок }  // Проверка наличия ошибок. if len(errs) > 0 { return nil, fmt.Errorf("ошибки при получении объектов: %v", errs) // Возврат ошибки, если возникли ошибки при получении объектов }  return urls, nil // Возврат массива URL-адресов, если ошибок не возникло }
    5. DeleteOne

      // DeleteOne удаляет один объект из бакета Minio по его идентификатору. func (m *minioClient) DeleteOne(objectID string) error { // Удаление объекта из бакета Minio. err := m.mc.RemoveObject(context.Background(), config.AppConfig.BucketName, objectID, minio.RemoveObjectOptions{}) if err != nil { return err // Возвращаем ошибку, если не удалось удалить объект. } return nil // Возвращаем nil, если объект успешно удалён. }
    6. DeleteMany

      // DeleteMany удаляет несколько объектов из бакета Minio по их идентификаторам с использованием горутин. func (m *minioClient) DeleteMany(objectIDs []string) error { // Создание канала для передачи ошибок с размером, равным количеству объектов для удаления errCh := make(chan helpers.OperationError, len(objectIDs)) // Канал для ошибок var wg sync.WaitGroup                                      // WaitGroup для ожидания завершения всех горутин  ctx, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции defer cancel()                                          // Отложенный вызов функции отмены контекста при завершении функции DeleteMany  // Запуск горутин для удаления каждого объекта. for _, objectID := range objectIDs { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины go func(id string) { defer wg.Done()                                                                             // Уменьшение счетчика WaitGroup после завершения горутины err := m.mc.RemoveObject(ctx, config.AppConfig.BucketName, id, minio.RemoveObjectOptions{}) // Удаление объекта с использованием Minio клиента if err != nil { errCh <- helpers.OperationError{ObjectID: id, Error: fmt.Errorf("ошибка при удалении объекта %s: %v", id, err)} // Отправка ошибки в канал с ошибками cancel()                                                                                                        // Отмена операции при возникновении ошибки } }(objectID) // Передача идентификатора объекта в анонимную горутину }  // Ожидание завершения всех горутин и закрытие канала с ошибками. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0 close(errCh) // Закрытие канала с ошибками после завершения всех горутин }()  // Сбор ошибок из канала. var errs []error // Массив для хранения ошибок for opErr := range errCh { errs = append(errs, opErr.Error) // Добавление ошибки в массив ошибок }  // Проверка наличия ошибок. if len(errs) > 0 { return fmt.Errorf("ошибки при удалении объектов: %v", errs) // Возврат ошибки, если возникли ошибки при удалении объектов }  return nil // Возврат nil, если ошибок не возникло } 
    7. Полный файл:

    package minio  import ( "bytes" "context" "fmt" "github.com/google/uuid" "github.com/minio/minio-go/v7" "minio-gin-crud/internal/common/config" "minio-gin-crud/pkg/minio/helpers" "sync" "time" )  // Контекст используется для передачи сигналов об отмене операции загрузки в случае необходимости.  // CreateOne создает один объект в бакете Minio. // Метод принимает структуру fileData, которая содержит имя файла и его данные. // В случае успешной загрузки данных в бакет, метод возвращает nil, иначе возвращает ошибку. // Все операции выполняются в контексте задачи. func (m *minioClient) CreateOne(file helpers.FileDataType) (string, error) { // Генерация уникального идентификатора для нового объекта. objectID := uuid.New().String()  // Создание потока данных для загрузки в бакет Minio. reader := bytes.NewReader(file.Data)  // Загрузка данных в бакет Minio с использованием контекста для возможности отмены операции. _, err := m.mc.PutObject(context.Background(), config.AppConfig.BucketName, objectID, reader, int64(len(file.Data)), minio.PutObjectOptions{}) if err != nil { return "", fmt.Errorf("ошибка при создании объекта %s: %v", file.FileName, err) }  // Получение URL для загруженного объекта url, err := m.mc.PresignedGetObject(context.Background(), config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { return "", fmt.Errorf("ошибка при создании URL для объекта %s: %v", file.FileName, err) }  return url.String(), nil }  // CreateMany создает несколько объектов в хранилище MinIO из переданных данных. // Если происходит ошибка при создании объекта, метод возвращает ошибку, // указывающую на неудачные объекты. func (m *minioClient) CreateMany(data map[string]helpers.FileDataType) ([]string, error) { urls := make([]string, 0, len(data)) // Массив для хранения URL-адресов  ctx, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции. defer cancel()                                          // Отложенный вызов функции отмены контекста при завершении функции CreateMany.  // Создание канала для передачи URL-адресов с размером, равным количеству переданных данных. urlCh := make(chan string, len(data))  var wg sync.WaitGroup // WaitGroup для ожидания завершения всех горутин.  // Запуск горутин для создания каждого объекта. for objectID, file := range data { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины. go func(objectID string, file helpers.FileDataType) { defer wg.Done()                                                                                                                                   // Уменьшение счетчика WaitGroup после завершения горутины. _, err := m.mc.PutObject(ctx, config.AppConfig.BucketName, objectID, bytes.NewReader(file.Data), int64(len(file.Data)), minio.PutObjectOptions{}) // Создание объекта в бакете MinIO. if err != nil { cancel() // Отмена операции при возникновении ошибки. return }  // Получение URL для загруженного объекта url, err := m.mc.PresignedGetObject(ctx, config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { cancel() // Отмена операции при возникновении ошибки. return }  urlCh <- url.String() // Отправка URL-адреса в канал с URL-адресами. }(objectID, file) // Передача данных объекта в анонимную горутину. }  // Ожидание завершения всех горутин и закрытие канала с URL-адресами. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0. close(urlCh) // Закрытие канала с URL-адресами после завершения всех горутин. }()  // Сбор URL-адресов из канала. for url := range urlCh { urls = append(urls, url) // Добавление URL-адреса в массив URL-адресов. }  return urls, nil }  // GetOne получает один объект из бакета Minio по его идентификатору. // Он принимает строку `objectID` в качестве параметра и возвращает срез байт данных объекта и ошибку, если такая возникает. func (m *minioClient) GetOne(objectID string) (string, error) { // Получение предварительно подписанного URL для доступа к объекту Minio. url, err := m.mc.PresignedGetObject(context.Background(), config.AppConfig.BucketName, objectID, time.Second*24*60*60, nil) if err != nil { return "", fmt.Errorf("ошибка при получении URL для объекта %s: %v", objectID, err) }  return url.String(), nil }  // GetMany получает несколько объектов из бакета Minio по их идентификаторам. func (m *minioClient) GetMany(objectIDs []string) ([]string, error) { // Создание каналов для передачи URL-адресов объектов и ошибок urlCh := make(chan string, len(objectIDs))                 // Канал для URL-адресов объектов errCh := make(chan helpers.OperationError, len(objectIDs)) // Канал для ошибок  var wg sync.WaitGroup                                 // WaitGroup для ожидания завершения всех горутин _, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции defer cancel()                                        // Отложенный вызов функции отмены контекста при завершении функции GetMany  // Запуск горутин для получения URL-адресов каждого объекта. for _, objectID := range objectIDs { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины go func(objectID string) { defer wg.Done()                // Уменьшение счетчика WaitGroup после завершения горутины url, err := m.GetOne(objectID) // Получение URL-адреса объекта по его идентификатору с помощью метода GetOne if err != nil { errCh <- helpers.OperationError{ObjectID: objectID, Error: fmt.Errorf("ошибка при получении объекта %s: %v", objectID, err)} // Отправка ошибки в канал с ошибками cancel()                                                                                                                     // Отмена операции при возникновении ошибки return } urlCh <- url // Отправка URL-адреса объекта в канал с URL-адресами }(objectID) // Передача идентификатора объекта в анонимную горутину }  // Закрытие каналов после завершения всех горутин. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0 close(urlCh) // Закрытие канала с URL-адресами после завершения всех горутин close(errCh) // Закрытие канала с ошибками после завершения всех горутин }()  // Сбор URL-адресов объектов и ошибок из каналов. var urls []string // Массив для хранения URL-адресов var errs []error  // Массив для хранения ошибок for url := range urlCh { urls = append(urls, url) // Добавление URL-адреса в массив URL-адресов } for opErr := range errCh { errs = append(errs, opErr.Error) // Добавление ошибки в массив ошибок }  // Проверка наличия ошибок. if len(errs) > 0 { return nil, fmt.Errorf("ошибки при получении объектов: %v", errs) // Возврат ошибки, если возникли ошибки при получении объектов }  return urls, nil // Возврат массива URL-адресов, если ошибок не возникло }  // DeleteOne удаляет один объект из бакета Minio по его идентификатору. func (m *minioClient) DeleteOne(objectID string) error { // Удаление объекта из бакета Minio. err := m.mc.RemoveObject(context.Background(), config.AppConfig.BucketName, objectID, minio.RemoveObjectOptions{}) if err != nil { return err // Возвращаем ошибку, если не удалось удалить объект. } return nil // Возвращаем nil, если объект успешно удалён. }  // DeleteMany удаляет несколько объектов из бакета Minio по их идентификаторам с использованием горутин. func (m *minioClient) DeleteMany(objectIDs []string) error { // Создание канала для передачи ошибок с размером, равным количеству объектов для удаления errCh := make(chan helpers.OperationError, len(objectIDs)) // Канал для ошибок var wg sync.WaitGroup                                      // WaitGroup для ожидания завершения всех горутин  ctx, cancel := context.WithCancel(context.Background()) // Создание контекста с возможностью отмены операции defer cancel()                                          // Отложенный вызов функции отмены контекста при завершении функции DeleteMany  // Запуск горутин для удаления каждого объекта. for _, objectID := range objectIDs { wg.Add(1) // Увеличение счетчика WaitGroup перед запуском каждой горутины go func(id string) { defer wg.Done()                                                                             // Уменьшение счетчика WaitGroup после завершения горутины err := m.mc.RemoveObject(ctx, config.AppConfig.BucketName, id, minio.RemoveObjectOptions{}) // Удаление объекта с использованием Minio клиента if err != nil { errCh <- helpers.OperationError{ObjectID: id, Error: fmt.Errorf("ошибка при удалении объекта %s: %v", id, err)} // Отправка ошибки в канал с ошибками cancel()                                                                                                        // Отмена операции при возникновении ошибки } }(objectID) // Передача идентификатора объекта в анонимную горутину }  // Ожидание завершения всех горутин и закрытие канала с ошибками. go func() { wg.Wait()    // Блокировка до тех пор, пока счетчик WaitGroup не станет равным 0 close(errCh) // Закрытие канала с ошибками после завершения всех горутин }()  // Сбор ошибок из канала. var errs []error // Массив для хранения ошибок for opErr := range errCh { errs = append(errs, opErr.Error) // Добавление ошибки в массив ошибок }  // Проверка наличия ошибок. if len(errs) > 0 { return fmt.Errorf("ошибки при удалении объектов: %v", errs) // Возврат ошибки, если возникли ошибки при удалении объектов }  return nil // Возврат nil, если ошибок не возникло } 
    1. helpers : error | type

      package helpers  type FileDataType struct { FileName string Data     []byte }  ////////// в разных файлах в папочке helpers //////////  package helpers  type OperationError struct { ObjectID string Error    error } 

      На этом сервис Minio готов — осталось только написать хендлеры и можно запускать проект!

      На текущем этапе его можно и запустить:
      — запустить docker-compose : docker-compose up —build
      — запустить сервер : go run cmd/main.go

      Но тестировать нечего — надо писать хендлеры — давай займемся этим

  5. Minio handlers

    Для начала необходимо описать основные структуры, которые му будем использовать:
    — Структура хендлера в пакете minioHandler — показывает что должен принять в себя хендлер, какие сервисы он будет использовать (только minio service — что неудивительно)

    package minioHandler  import "minio-gin-crud/pkg/minio"  type Handler struct { minioService minio.Client }  func NewMinioHandler( minioService minio.Client, ) *Handler { return &Handler{ minioService: minioService, } } 

    Основной handler, отвечающий также за регистрацию роутов:

    package handler  import ( "github.com/gin-gonic/gin" "minio-gin-crud/internal/handler/minioHandler" "minio-gin-crud/pkg/minio" )  // Services структура всех сервисов, которые используются в хендлерах // Это нужно чтобы мы могли использовать внутри хендлеров эти самые сервисы type Services struct { minioService minio.Client // Сервис у нас только один - minio, мы планируем его использовать, поэтому передаем }  // Handlers структура всех хендлеров, которые используются для обозначения действия в роутах type Handlers struct { minioHandler minioHandler.Handler // Пока у нас только один роут }  // NewHandler создает экземпляр Handler с предоставленными сервисами func NewHandler( minioService minio.Client, ) (*Services, *Handlers) { return &Services{ minioService: minioService, }, &Handlers{ // инициируем Minio handler, который на вход получает minio service minioHandler: *minioHandler.NewMinioHandler(minioService), } }  // RegisterRoutes - метод регистрации всех роутов в системе func (h *Handlers) RegisterRoutes(router *gin.Engine) {  // Здесь мы обозначили все эндпоинты системы с соответствующими хендлерами minioRoutes := router.Group("/files") { minioRoutes.POST("/", h.minioHandler.CreateOne) minioRoutes.POST("/many", h.minioHandler.CreateMany)  minioRoutes.GET("/:objectID", h.minioHandler.GetOne) minioRoutes.GET("/many", h.minioHandler.GetMany)  minioRoutes.DELETE("/:objectID", h.minioHandler.DeleteOne) minioRoutes.DELETE("/many", h.minioHandler.DeleteMany) }  }

    Основные типы данных в файлах errors / responses / dto

    package dto  // Нужен когда в body приходит много objectId - GetMany / DeleteMany type ObjectIdsDto struct { ObjectIDs []string `json:"objectIDs"` }  ////  package errors  // Нужен для JSON ответов в случае неправильной работы сервиса type ErrorResponse struct { Error   string      `json:"error"` Status  int         `json:"code,omitempty"` Details interface{} `json:"details,omitempty"` }  ////  package responses  // Нужен для JSON ответов в случае правильной работы сервиса type SuccessResponse struct { Status  int         `json:"status"` Message string      `json:"message"` Data    interface{} `json:"data,omitempty"` } 

    Теперь можно писать handlers — я так же буду прилагать скрины из постмана как я протестировал эти методы:

    1. CreateOne

      // CreateOne обработчик для создания одного объекта в хранилище MinIO из переданных данных. func (h *Handler) CreateOne(c *gin.Context) { // Получаем файл из запроса file, err := c.FormFile("file") if err != nil { // Если файл не получен, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusBadRequest, errors.ErrorResponse{ Status:  http.StatusBadRequest, Error:   "No file is received", Details: err, }) return }  // Открываем файл для чтения f, err := file.Open() if err != nil { // Если файл не удается открыть, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to open the file", Details: err, }) return } defer f.Close() // Закрываем файл после завершения работы с ним  // Читаем содержимое файла в байтовый срез fileBytes, err := io.ReadAll(f) if err != nil { // Если не удается прочитать содержимое файла, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to read the file", Details: err, }) return }  // Создаем структуру FileDataType для хранения данных файла fileData := helpers.FileDataType{ FileName: file.Filename, // Имя файла Data:     fileBytes,     // Содержимое файла в виде байтового среза }  // Сохраняем файл в MinIO с помощью метода CreateOne link, err := h.minioService.CreateOne(fileData) if err != nil { // Если не удается сохранить файл, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to save the file", Details: err, }) return }  // Возвращаем успешный ответ с URL-адресом сохраненного файла c.JSON(http.StatusOK, responses.SuccessResponse{ Status:  http.StatusOK, Message: "File uploaded successfully", Data:    link, // URL-адрес загруженного файла }) }

      Успешный результат:

      результаты мне нравятся

      результаты мне нравятся
    2. CreateMany

      // CreateMany обработчик для создания нескольких объектов в хранилище MinIO из переданных данных. func (h *Handler) CreateMany(c *gin.Context) { // Получаем multipart форму из запроса form, err := c.MultipartForm() if err != nil { // Если форма недействительна, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusBadRequest, errors.ErrorResponse{ Status:  http.StatusBadRequest, Error:   "Invalid form", Details: err, }) return }  // Получаем файлы из формы files := form.File["files"] if files == nil { // Если файлы не получены, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusBadRequest, errors.ErrorResponse{ Status:  http.StatusBadRequest, Error:   "No files are received", Details: err, }) return }  // Создаем map для хранения данных файлов data := make(map[string]helpers.FileDataType)  // Проходим по каждому файлу в форме for _, file := range files { // Открываем файл f, err := file.Open() if err != nil { // Если файл не удается открыть, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to open the file", Details: err, }) return } defer f.Close() // Закрываем файл после завершения работы с ним  // Читаем содержимое файла в байтовый срез fileBytes, err := io.ReadAll(f) if err != nil { // Если не удается прочитать содержимое файла, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to read the file", Details: err, }) return }  // Добавляем данные файла в map data[file.Filename] = helpers.FileDataType{ FileName: file.Filename, // Имя файла Data:     fileBytes,     // Содержимое файла в виде байтового среза } }  // Сохраняем файлы в MinIO с помощью метода CreateMany links, err := h.minioService.CreateMany(data) if err != nil { // Если не удается сохранить файлы, возвращаем ошибку с соответствующим статусом и сообщением fmt.Printf("err: %+v\n ", err.Error()) c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Unable to save the files", Details: err, }) return }  // Возвращаем успешный ответ с URL-адресами сохраненных файлов c.JSON(http.StatusOK, responses.SuccessResponse{ Status:  http.StatusOK, Message: "Files uploaded successfully", Data:    links, // URL-адреса загруженных файлов }) } 
      success

      success
    3. GetOne

      // GetOne обработчик для получения одного объекта из бакета Minio по его идентификатору. func (h *Handler) GetOne(c *gin.Context) { // Получаем идентификатор объекта из параметров URL objectID := c.Param("objectID")  // Используем сервис MinIO для получения ссылки на объект link, err := h.minioService.GetOne(objectID) if err != nil { // Если произошла ошибка при получении объекта, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Enable to get the object", Details: err, }) return }  // Возвращаем успешный ответ с URL-адресом полученного файла c.JSON(http.StatusOK, responses.SuccessResponse{ Status:  http.StatusOK, Message: "File received successfully", Data:    link, // URL-адрес полученного файла }) } 
      успешный успех!

      успешный успех!
    4. GetMany

      // GetMany обработчик для получения нескольких объектов из бакета Minio по их идентификаторам. func (h *Handler) GetMany(c *gin.Context) { // Объявление переменной для хранения получаемых идентификаторов объектов var objectIDs dto.ObjectIdsDto  // Привязка JSON данных из запроса к переменной objectIDs if err := c.ShouldBindJSON(&objectIDs); err != nil { // Если привязка данных не удалась, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusBadRequest, errors.ErrorResponse{ Status:  http.StatusBadRequest, Error:   "Invalid request body", Details: err, }) return }  // Используем сервис MinIO для получения ссылок на объекты по их идентификаторам links, err := h.minioService.GetMany(objectIDs.ObjectIDs) if err != nil { // Если произошла ошибка при получении объектов, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Enable to get many objects", Details: err, }) return }  // Возвращаем успешный ответ с URL-адресами полученных файлов c.JSON(http.StatusOK, gin.H{ "status":  http.StatusOK, "message": "Files received successfully", "data":    links, // URL-адреса полученных файлов }) }
      вот только не придумал что делать с ссылками - если знаете

      вот только не придумал что делать с ссылками — какие-то они некрасивые
    5. DeleteOne

      // DeleteOne обработчик для удаления одного объекта из бакета Minio по его идентификатору. func (h *Handler) DeleteOne(c *gin.Context) { objectID := c.Param("objectID")  if err := h.minioService.DeleteOne(objectID); err != nil { c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Cannot delete the object", Details: err, }) return }  c.JSON(http.StatusOK, responses.SuccessResponse{ Status:  http.StatusOK, Message: "File deleted successfully", }) }
      удаление одного файла в базе

      удаление одного файла в базе
    6. DeleteMany

      // DeleteMany обработчик для удаления нескольких объектов из бакета Minio по их идентификаторам. func (h *Handler) DeleteMany(c *gin.Context) { // Объявление переменной для хранения получаемых идентификаторов объектов var objectIDs dto.ObjectIdsDto  // Привязка JSON данных из запроса к переменной objectIDs if err := c.BindJSON(&objectIDs); err != nil { // Если привязка данных не удалась, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusBadRequest, errors.ErrorResponse{ Status:  http.StatusBadRequest, Error:   "Invalid request body", // Сообщение об ошибке в запросе Details: err,                    // Детали ошибки }) return }  // Используем сервис MinIO для удаления объектов по их идентификаторам if err := h.minioService.DeleteMany(objectIDs.ObjectIDs); err != nil { // Если произошла ошибка при удалении объектов, возвращаем ошибку с соответствующим статусом и сообщением c.JSON(http.StatusInternalServerError, errors.ErrorResponse{ Status:  http.StatusInternalServerError, Error:   "Cannot delete many objects", // Сообщение об ошибке удаления объектов Details: err,                          // Детали ошибки }) return }  // Возвращаем успешный ответ, если объекты успешно удалены c.JSON(http.StatusOK, responses.SuccessResponse{ Status:  http.StatusOK, Message: "Files deleted successfully", // Сообщение об успешном удалении файлов }) } 
      удаление многих объект

      удаление многих объект
  6. Main file

    Сейчас надо добавить регистрацию роутов в main.go файл и теперь можно запускать проект:

    package main  import ( "github.com/gin-gonic/gin" "log" "minio-gin-crud/internal/common/config" "minio-gin-crud/internal/handler" "minio-gin-crud/pkg/minio" )  func main() { // Загрузка конфигурации из файла .env config.LoadConfig()  // Инициализация соединения с Minio minioClient := minio.NewMinioClient() err := minioClient.InitMinio() if err != nil { log.Fatalf("Ошибка инициализации Minio: %v", err) }  _, s := handler.NewHandler( minioClient, )  // Инициализация маршрутизатора Gin router := gin.Default()  s.RegisterRoutes(router)  // Запуск сервера Gin port := config.AppConfig.Port // Мы берем порт из конфига err = router.Run(":" + port) if err != nil { log.Fatalf("Ошибка запуска сервера Gin: %v", err) } } 

  7. Запуск и тестирование

    После запуска станет доступно 6 роутов:

    • POST http://localhost:8080/files — createOne

    • POST http://localhost:8080/files/many — createMany

    • GET http://localhost:8080/files/:objectId — getOne

    • GET http://localhost:8080/files/many — getMany {objectIDs: []string}

    • DELETE http://localhost:8080/files/:objectId — deleteOne

    • DELETE http://localhost:8080/files/many — deleteMany {objectIDs: []string}

  8. Добавим .env файл, в который добавим переменные окружения

    MINIO_ENDPOINT=localhost:9000 MINIO_ROOT_USER=root MINIO_ROOT_PASSWORD=zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG MINIO_BUCKET_NAME=test-bucket MINIO_USE_SSL=false FILE_TIME_EXPIRATION=24 # в часах  PORT=8080

Все — наслаждайтесь своим проектом!

Полный код будет в моем GitHub. Буду рад если оцените проект и статью звездочкой на GitHub! Оказывается на написание подобных статей уходит большое количество мыслетоплива

Если будут вопросы — вы знаете где меня искать:

Me:
— telegram
— telegram channel
— GitHub


ссылка на оригинал статьи https://habr.com/ru/articles/818853/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *