В этой статье мы рассмотрим создание микросервиса обработки изображений на golang с использованием технологии **gRPC**. Цель статьи - показать как может выглядеть такой сервис и что он может в себя включать. В результате мы получим полностью рабочий сервис по обработке изображений, который принимает данные, сохраняет исходную картинку,сжимает её, накладывает на неё ватермарку, изменяет размер изображения, и конвертирует его в нужный формат.
Разберём возможные варианты взаимодействия клиента с сервером для обработки больших объектов, в нашем случае это картинки:
1. HTTP/1.1 (REST)
Передача изображений в виде текстовых чанков (например, base64) приводит к значительным накладным расходам: бинарные данные увеличиваются на ~33% при кодировании в Base64, а текстовый формат неэффективен для больших объёмов.
2. WebSocket
Подходит для долгоживущих сессий и двустороннего обмена, но избыточен, если нам нужно просто «принять изображение → обработать → вернуть результат». Удержание тысяч соединений ради однократных операций — неоптимально.
3. gRPC использует:
Protocol Buffers — строго типизированный, компактный бинарный формат,
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
protoc-gen-go-grpc
Позволяет генерировать определения сервисов Go для буфера протокола, заданного нашим .proto файлом protoc-gen-go-grpc
Также для работы webp нам потребуется работа с CGO, которую мы рассмотрим отдельно.
III. Реализация proto файла
Для начала работы опишем наш proto файл. Это будет сервис с единственным rpc который будет обрабатывать картинки пользователей.
./proto/image.proto
message DownloadImagesRequest {
ImageInfo info = 1; //параметры обработки изображения
bytes image = 2; // непосредственно данные изображения
}
message ImageInfo {
string compress = 1; // сжатие
string watermark = 2; // вотермарк
string format = 3; // перевод в формат файла
repeated int32 width = 4; // ширина обработанной картинки
repeated int32 height = 5; // высота обработанной картинки
}
message DownloadImagesResponse {
repeated string storage_path = 1; // ссылка куда сохранить
string error=2;
}
/* При передаче файлов больших размеров клиент передает нам данные потоком, сервер дожидается окончания передачи , обрабатывет запрос и отдает пользователю ответ. */
service ImageService {
rpc DownloadImages(stream DownloadImagesRequest) returns (DownloadImagesResponse);
}
Мы используем Client-Streaming RPC — клиент может отправить несколько сообщений, сервер — один ответ. Этот способ поможет нам в случае необходимости гибкого расширения и избежания ограничений на размер одного сообщения.
V. Реализация основных функций
1. Создание сервера
1.1 Генерация go файлов из .proto: Сгенерируем файлы для реализации сервера с помощью protoc:
Теперь реализуем функцию запуска нашего grpc сервера:
...
func (s *GrpcServer) GrpcServeServer(a ImageServer, adress string) error {
lis, err := net.Listen("tcp", adress)
if err != nil {
slog.Error("address for grpc server not found, attempting graceful shutdown")
s.Server.GracefulStop()
return err
}
pb.RegisterImageServiceServer(s.Server, a)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
<-sigCh
slog.Error("got signal 1, attempting graceful shutdown")
s.Server.GracefulStop()
wg.Done()
}()
slog.Info("starting grpc server", "address", adress)
if err := s.Server.Serve(lis); err != nil {
slog.Error("grpc server error", "error", err.Error())
s.Server.GracefulStop()
return err
}
wg.Wait()
return nil
}
Мы создали наш grpc сервер, но пока нет никакой реализации ImageServiceServer который сгенерировал нам protoc, это просто интерфейс который нам и нужно реализовать.
2. Реализация ImageServiceServer
Нам необходимо создать структуру ImageServer которая реализует интерфейс ImageServiceServer с его методом DownloadImage, также создадим конструктор для него :
./internal/app/image.go
package app
import (
"errors"
pb "image-converter/proto"
"io"
"strconv"
)
type ImageServer struct {
pb.ImageServiceServer
}
func NewImageServer() ImageServer {
i := ImageServer{}
return i
}
// создадим структуру OriginalImage которая будет хранить метаданные о наших картинках
type OriginalImage struct {
Path string
Lenght []int32
Width []int32
Format string
Folder string
Watermark string
UUID string
}
const defaultWatermark = "watermark.png"
func (img ImageServer) DownloadImages(stream pb.ImageService_DownloadImagesServer) error {
var images []*pb.DownloadImagesRequest
for {
image, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return stream.SendAndClose(&pb.DownloadImagesResponse{
Error: err.Error(),
})
}
images = append(images, image)
}
var paths []OriginalImage
for i := range images {
if len(images[i].Info.Height) != len(images[i].Info.Width) {
return stream.SendAndClose(&pb.DownloadImagesResponse{
Error: "different len of lenght and width for picture " + strconv.Itoa(i),
})
}
if images[i].Info.Watermark == "" {
images[i].Info.Watermark = defaultWatermark
}
}
paths = saveSourceFiles(images)
if len(images) == 0 {
return stream.SendAndClose(&pb.DownloadImagesResponse{
Error: "no images in request",
})
}
err := watermark(paths)
if err != nil {
return stream.SendAndClose(&pb.DownloadImagesResponse{
Error: errors.New("path for watermark is invalid").Error(),
})
}
uploadPath := resizeAndSave(paths)
res := &pb.DownloadImagesResponse{
StoragePath: uploadPath,
}
err = stream.SendAndClose(res)
if err != nil {
return stream.SendAndClose(&pb.DownloadImagesResponse{
Error: "error when receive an responce",
})
}
return nil
}
Нам осталось реализовать ключевые функции для нашего сервиса, а именно: saveSourceFiles: отвечает за сохранение исходных файлов и их сжатие, watermark: наложение ватермарки , resizeAndSave: изменения размера картинки и его конвертация в нужный формат изображения.
3. Сохранение оригинала
Мы создаем путь для сохранения картинки, сохраняем её с необходимым для клиента уровнем сжатия и потом для удобства всю метаинформацию об картинке помещаем уже в нашу структуру и работаем в дальнейшем только с ней:
./internal/app/save.go
package app
import (
"bytes"
"fmt"
"image"
pb "image-converter/proto"
"image/jpeg"
"image/png"
"log/slog"
"os"
sync "sync"
"time"
"github.com/chai2010/webp"
"github.com/google/uuid"
)
func getTimeData() (string, string, string) {
year := time.Now().Year()
month := time.Now().Month()
day := time.Now().Day()
y := fmt.Sprintf("%v", year)
m2 := int(month)
m := fmt.Sprintf("%v", m2)
d := fmt.Sprintf("%v", day)
return y, m, d
}
func setLevelCompressionPNG(level string) png.CompressionLevel {
var compessInt png.CompressionLevel
switch level {
case "low": compessInt = -3
case "medium": compessInt = -2
case "max": compessInt = -1
}
return compessInt
}
func setLevelCompressionJPG(level string) int {
var compessInt int
switch level {
case "low": compessInt = 10
case "medium": compessInt = 50
case "max": compessInt = 100
}
return compessInt
}
func setLevelCompressionWEBP(level string) float32 {
var compessInt float32
switch level {
case "low": compessInt = 10
case "medium": compessInt = 50
case "max": compessInt = 100
}
return compessInt
}
func saveSourceFiles(images []*pb.DownloadImagesRequest) []OriginalImage {
var wg sync.WaitGroup
var mu sync.Mutex
y, m, d := getTimeData()
imagesNew := make([]OriginalImage, 0)
for i := range images {
wg.Add(1)
go func(i int) {
defer wg.Done()
extension := images[i].Info.Format
uuid := uuid.New().String()
newFileName := uuid + "." + extension
newFilePath := "./download/" + y + "/" + m + "/" + d + "/" + uuid + "/" + "img/"
err := os.MkdirAll(newFilePath, 0755)
if err != nil { slog.Error("failed to create directory", "error", err.Error()) }
img, _, err := image.Decode(bytes.NewReader(images[i].Image))
if err != nil { slog.Error("failed to decode image", "error", err.Error()); return }
path := newFilePath + newFileName
out, _ := os.Create(path)
defer out.Close()
switch extension {
case "png":
var enc png.Encoder
level := setLevelCompressionPNG(images[i].Info.Compress)
enc.CompressionLevel = level
err = enc.Encode(out, img)
if err != nil { slog.Error("failed to encode PNG", "error", err.Error()) }
case "jpeg", "jpg":
var opts jpeg.Options
level := setLevelCompressionJPG(images[i].Info.Compress)
opts.Quality = level
err = jpeg.Encode(out, img, &opts)
if err != nil { slog.Error("failed to encode JPEG", "error", err.Error()) }
case "webp":
var data []byte
level := setLevelCompressionWEBP(images[i].Info.Compress)
data, err = webp.EncodeRGB(img, level)
if err != nil { slog.Error("failed to encode WEBP", "error", err.Error()) }
if err = os.WriteFile(path, data, 0666); err != nil { slog.Error("failed to write WEBP file", "error", err.Error()) }
default:
slog.Error("unknown file format, please provide a file with extension png,jpg,webp")
}
imageNew := OriginalImage{Path: path, Lenght: images[i].Info.Height, Width: images[i].Info.Width, Format: images[i].Info.Format, Folder: newFilePath, Watermark: images[i].Info.Watermark, UUID: uuid}
mu.Lock()
imagesNew = append(imagesNew, imageNew)
mu.Unlock()
}(i)
wg.Wait()
}
return imagesNew
}
Для ускорения нашего процесса обработки все этапы мы будем выполнять конкурентно с помощью горутин.
4. Водяной знак
Следующим этапом идёт наложение водяного знака на нашу картинку. Процесс наложения: мы передаем каждой горутине по изображению, они его обрабатывают, а для того чтобы убедиться что они все обработались мы применяем sync.WaitGroup. Сам процесс наложения водяного знака это задание параметров для установки его на исходную картинку, в нашем случае мы делаем её полупрозрачную с небольшим углом поворота и в случайном месте и сохраняем её:
Наше приложение полностью готово, но теперь нужно удостовериться, что у нас работает поддержка CGO. Для начала нужно убедиться что мы скачали и установили webp по ссылке [официальная страница Google](https://developers.google.com/speed/webp/download?hl=ru). Далее нам необходимо установить GCC, без него go не может распознать импортированные C файлы, скачиваем и устанавливаем [официальные зеркала GNU](https://gcc.gnu.org/mirrors.html). Теперь нужно проверить, что переменная CGO_ENABLED=1, для этого используем команду go env и проверяем, если она равна 0, то используем go set CGO_ENABLED=1 и проверяем ,что она применилась, если нет то перезагружаем нашу систему и проверяем. Теперь мы готовы собрать наше приложение и приступить к тестированию.
VII. Тестирование и оптимизация
Тестирование с Bruno
Здесь вы можете тестировать удобными для вас средствами такими как Postman, Bruno, Yaak, grpccurl и т.д., главное чтобы они поддерживали тестирование grpc методов. Рассмотрим пример с Bruno:
1.Конвертируйте изображение в Base64: Image to Base64 Converter
2.Откройте Bruno создайте новый grpc запрос
3. Выбираем метод grpc:
4.Уберите ползунок с reflection и укажите .proto файл
5.Выберите метод DownloadImage
6.В поле message заполните поле в соответствии с параметрами, например:
если появляются проблемы при подключении к сервису, то не выносите текстовое представление картинки в переменную
В поле image нужно скопировать текстовую строку ,что идёт после запятой, сгенерированную на шаге 1
7.Далее нужно запустить наше приложение и подключиться к нему с помощью кнопки →
В случае успеха должен появиться статус streaming
8.Отправляем наше сообщение/сообщения нажав на кнопку "Send message" один или несколько раз и завершаем нашу передачу сообщений с помощью кнопки → (если не нажать то приложение будет ожидать приёма сообщений). Результатом будет возврат путей наших обработанных сообщений
VIII. Заключение
Подведем итоги, мы создали сервис который:
Использует gRPC для эффективной передачи данных,
Поддерживает WebP, JPEG, PNG,
Безопасен в конкурентной среде,
Можно легко масштабировать.
В результате получился сервис обработки изображений. Этот подход применим не только к изображениям, но и к любым бинарным данным.