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