Добрейшего! Недавно мой друг решил хранить картинки продуктов на сервере, в отдельной папочке, выдав ей публичный доступ. Что ж, эту статью я пишу чтобы рассказать другу плюсы и минусы, а так же показать как можно делать иначе.
Если вы только изучаете 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 хранилища, что покажет вашу компетентность в теме.
Далее о том как это делается.
Интеграция S3 хранилища на языке Golang
Итак, что мы сделаем в этом туториале:
Напишем простой 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.modmkdir 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
Комментарии (10)
gohrytt
01.06.2024 15:56+3Ну хватит людей плохому учить, пожалуйста! У вас одна реализация интерфейса, второй не будет примерно никогда, зачем он здесь?
verbitsky-vladislav Автор
01.06.2024 15:56не совсем понял что вы имели ввиду - почему плохого и что вам не нравится тоже неясно
anaxita
01.06.2024 15:56+3Имеют ввиду что в вашем примере нет смысла создавать интерфейс т.к он ничего вам не даёт.
Для тестов? Их нет
Для избавления от зависимости? Вы интерфейс создали рядом с типом который его реализует, а не использует.
По сути интерфейс ради интерфейса
Про чтения файла в память вам уже написали. Что будет если у вас 2гб ОЗУ а вам пришлют файл на 4гб?
Не очень понял зачем в проект затащили Gin...если не используется ничего кроме роутера и хелпера для отправки json, откройте go.mod файл и подумайте, сколько реально вы по факту используете того, что импортнули
Имхо, всего 3 месяца на Go (судя по гитхабу), а уже статьи на всех ресурсах (полагаю одни и те же), телеграм с абсолютно разнородной инормацией.. если вы подписчиков хотите, вам бы не программирование, а что-нибудь другое выбрать)
nronnie
01.06.2024 15:56всего 3 месяца на Go
что-нибудь другое выбрать
Понятно, что тут только в коучи по Go :)) Я 20+ лет в C#, штук восемь, наверное, сертификатов MS по .NET и у меня до сих пор нигде ни одной статьи, и даже блога своего нет, не говоря уже о Телеграманале - не считаю себя достойным высокого звания Автора.
verbitsky-vladislav Автор
01.06.2024 15:56Поверьте, вам не нужно ни одного MS сертификата по .NET, чтобы написать статью или взять на себя ответственность, чтобы научить кого-то чему-то :))
Получать сертификаты, чтобы считать себя "достойным высокого знания Автора" - это либо перфекционизм, либо синдром самозванца
verbitsky-vladislav Автор
01.06.2024 15:56Про чтения файла в память вам уже написали. Что будет если у вас 2гб ОЗУ а вам пришлют файл на 4гб?
У gin дефолтный лимит на файлы 32mb - очевидно, что если в планах принимать файлы >32mb, то нужно и оптимизировать и, может, инструменты другие использовать
По интерфейсу - спасибо за объяснение, подумаю
Да, вы правы - в Go не так много времени - до этого 3 года писал на NodeJS, но это не мешает показывать другим людям как сделать что-то
Тут, например, кроме интерфейса, *неясной зависимости в виде Gin и reader'a более опытные люди не нашли ошибок, поэтому я считаю проект вполне удачным
nronnie
01.06.2024 15:56S3 обеспечивает ограничение доступа только на уровне bucket, что без отсутствия какой-то прослойки между ним и сетью делает его ограничения доступа чуть-чуть более чем бесполезными.
eri
01.06.2024 15:56а как правильно выдавать "изображения товаров" с s3 в мир? подписывать токен и проксипасс?
zelenin
а зачем мы файлы вычитываем из ридера в память? у нас же все работает с эффективными io.Reader - их и надо использовать.