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