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

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

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

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

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

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

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

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

Так мы получим небольшой проект, который можно будет реиспользовать в других проектах, научимся работать с 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/godotenv

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
Дальше круче: продолжаем

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. Каждый метод будет прокомментирован, но, тем не менее, если останется что-то непонятное — пишите в комментарии я или кто-то другой обязательно вам помогут!

  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

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

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

    удаление многих объект

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! Оказывается на написание подобных статей уходит большое количество мыслетоплива

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

© Habrahabr.ru