Go + Minio: как написать простой сервер для взаимодействия с файлами
- воскресенье, 2 июня 2024 г. в 00:00:15
 
Добрейшего! Недавно мой друг решил хранить картинки продуктов на сервере, в отдельной папочке, выдав ей публичный доступ. Что ж, эту статью я пишу чтобы рассказать другу плюсы и минусы, а так же показать как можно делать иначе.
Если вы только изучаете go, начинаете писать сервера, то обязательно посмотрите эту статью - для бекендера уметь работать с s3 хранилищем так же важно, как и уметь работать с реляционной / нереляционной базой данных и с key-value базой - это основа основ.

Разберемся почему же, все-таки, файлики важно хранить именно в s3 хранилище, а не на сервере
Безопасность данных:
Пример: Конфиденциальные документы (подпись об NDA) сотрудников находятся в публичной папке. Хакеры получают доступ и крадут личные данные, что приводит к утечке и юридическим последствиям.
Уязвимость к атакам:
Пример: Публичные изображения товаров на сервере подвергаются DDoS-атаке, делая сайт недоступным и приводя к потере продаж.
Отсутствие контроля версий и управления доступом:
Пример: Публичные изображения товаров хранятся в папке на сервере. В случае случайного удаления или изменения изображений нет возможности восстановить предыдущие версии. Это приводит к потере важных данных, чего можно избежать с использованием системы контроля версий в облачном хранилище, таком как S3.
Нехватка масштабируемости:
Пример: Пользовательские данные хранятся на одном сервере. По мере роста данных сервер перегружается, замедляя работу приложения. Облачные решения, такие как S3, позволяют легко масштабировать хранилище.
Управление резервными копиями и восстановлением:
Пример: Важные документы хранятся на локальном сервере. Аппаратный сбой приводит к потере данных, так как резервное копирование не проводилось. В облачном хранилище, таком как S3, резервные копии создаются автоматически.
Итого, использование специализированных решений для хранения данных, таких как S3, обеспечивает более высокий уровень безопасности, гибкости и управляемости, что делает их предпочтительным выбором для большинства приложений.
В нашем примере мы будем использовать minio и вот почему:
Minio можно развернуть в Docker и Kubernetes - что делает его доступным
MinIO полностью совместим с API Amazon S3, что позволяет легко интегрировать его с существующими приложениями и инструментами, которые уже используют S3.
MinIO поддерживает шифрование данных как в процессе передачи (TLS), так и в состоянии покоя. Это обеспечивает высокий уровень безопасности и защиты данных, что особенно важно для конфиденциальной информации.
MinIO - это программное обеспечение с открытым исходным кодом, что делает его экономически выгодным решением по сравнению с коммерческими аналогами.
MinIO имеет активное сообщество пользователей и разработчиков, что обеспечивает доступ к обновлениям, исправлениям и помощи при возникновении проблем.
Эти проблемы, например, можно обозначить при предложении компании перейти на S3 хранилища, что покажет вашу компетентность в теме.
Далее о том как это делается.
Итак, что мы сделаем в этом туториале:
Напишем простой web server с использованием gin
Добавим библиотеку для работы с переменными окружения, напишем config файл, который можно будет переиспользовать
Напишем minio client - клиент, отвечающий за подключение и работу с S3 хранилищем minio
Напишем 6 методов взаимодействия с S3 хранилищем minio
CreateOne
CreateMany
GetOne
GetMany
DeleteOne
DeleteMany
Напишем 6 хендлеров для взаимодействия с методами
Так мы получим небольшой проект, который можно будет реиспользовать в других проектах, научимся работать с S3 на Go, а конкретно с Minio
Создание структуры проекта, подготовка окружения:
Тыкс - структура проекта. Можно просто вставить список команд и у вас появится такая же.
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/godotenvDocker-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
Дальше круче: продолжаем
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 все недостающие методы, потому что до текущего момента проект нельзя будет запустить.
Minio service
Итак, тут му будем описывать все методы взаимодействия с Minio. Каждый метод будет прокомментирован, но, тем не менее, если останется что-то непонятное - пишите в комментарии я или кто-то другой обязательно вам помогут!
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
}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
}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
}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-адресов, если ошибок не возникло
}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, если объект успешно удалён.
}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, если ошибок не возникло
}
Полный файл:
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, если ошибок не возникло
}
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
Но тестировать нечего - надо писать хендлеры - давай займемся этим
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 - я так же буду прилагать скрины из постмана как я протестировал эти методы:
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-адрес загруженного файла
	})
}Успешный результат:

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-адреса загруженных файлов
	})
}

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-адрес полученного файла
	})
}

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-адреса полученных файлов
	})
}
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",
	})
}
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", // Сообщение об успешном удалении файлов
	})
}

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)
	}
}
Запуск и тестирование
После запуска станет доступно 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}
Добавим .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