В этой статье мы рассмотрим создание микросервиса обработки изображений на golang с использованием технологии gRPC. Цель статьи - показать как может выглядеть такой сервис и что он может в себя включать. В результате мы получим полностью рабочий сервис по обработке изображений, который принимает данные, сохраняет исходную картинку, сжимает её, накладывает на неё ватермарку, изменяет размер изображения, и конвертирует его в нужный формат.
Разберём возможные варианты взаимодействия клиента с сервером для обработки больших объектов, в нашем случае это картинки:
-
HTTP/1.1 (REST)
Передача изображений в виде текстовых чанков (например, base64) приводит к значительным накладным расходам: бинарные данные увеличиваются на ~33% при кодировании в Base64, а текстовый формат неэффективен для больших объёмов. -
WebSocket
Подходит для долгоживущих сессий и двустороннего обмена, но избыточен, если нам нужно просто «принять изображение → обработать → вернуть результат». Удержание тысяч соединений ради однократных операций — неоптимально. -
gRPC использует:
Protocol Buffers — строго типизированный, компактный бинарный формат,
HTTP/2 — мультиплексирование, потоки, сжатие заголовков,
Client-Streaming — идеально подходит для передачи одного большого файла (например, изображения) в одном вызове.
I. Постановка задачи
Сервис должен:
Принимать изображение и параметры (format, compress, watermark, width[], height[]),
Сохранять оригинал с уникальным путём (./download/YYYY/MM/DD/UUID/img/...),
Накладывать водяной знак,
Генерировать версии заданных размеров,
Сохранять полученные изображения,
Возвращать список путей.
Пример:
Запрос с width = [1920, 1280], height = [1080, 720], format = "webp", watermark = "logo.png"
→ Сервис вернёт два пути:
./download/2026/02/08/abc123/img/abc123_1920x1080.webp
./download/2026/02/08/abc124/img/abc124_1280x720.webp
II. Архитектура приложения

Обработка происходит в строгом порядке:
1.Приём → 2. Сохранение исходного файла→ 3. Сжатие → 4. Watermark → 5. Resize → 6. Конвертация → 7. Ответ.
Структура хранения:
./download/2026/02/08/a1b2c3d4-.../img/
├── a1b2c3d4-....jpg ← оригинал
├── a1b2c3d4-..._800x600.png
└── a1b2c3d4-..._1024x768.png
Необходимые инструменты:
Для работы с WebP мы используем библиотеку golang.org/x/image/webp, а исходные утилиты можно скачать на официальной странице Google.
Для генерации protobuff нам нужен protoc-gen-go
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 — клиент может отправить несколько сообщений, сервер — один ответ. Этот способ поможет нам в случае необходимости гибкого расширения и избежания ограничений на размер одного сообщения.
IV. Реализация основных функций
1. Создание сервера
1.1 Генерация go файлов из .proto:
Сгенерируем файлы для реализации сервера с помощью protoc:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative -I . image.proto
У нас должно получиться 2 файла image.pb.go и image_grpc.pb.go в директории proto
1.2 Реализуем конструктор сервера и interceptor (аналог middleware в grpc) восстановления после паники:
./internal/app/server.go
package app
import (
pb "image-converter/proto"
"log/slog"
"net"
"os"
"os/signal"
"sync"
"syscall"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type GrpcServer struct {
Server *grpc.Server
}
func recoveryFn(p any) (err error) {
return status.Errorf(codes.Unknown, "panic triggered: %v", p)
}
func NewGrpcServer() *GrpcServer {
return &GrpcServer{
Server: grpc.NewServer(
grpc.ChainStreamInterceptor(
recovery.StreamServerInterceptor(recovery.WithRecoveryHandler(recoveryFn)),
)),
}
}
Теперь реализуем функцию запуска нашего grpc сервера:
./internal/app/server.go
...
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",
})
}
//наложение watermark на наши изображения
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 {
//применяем WaitGroup чтобы дождаться сохранения всех картинок переданных пользователем
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. Сам процесс наложения водяного знака это задание параметров для установки его на исходную картинку, в нашем случае мы делаем её полупрозрачную с небольшим углом поворота и в случайном месте и сохраняем её:
./internal/app/watermark.go
package app
import (
"os"
sync "sync"
"github.com/disintegration/imaging"
"github.com/filipenevs/go-imagewatermark"
)
func watermark(paths []OriginalImage) error {
var wg sync.WaitGroup
var mu sync.Mutex
var err error
//получение текущего каталога для последующего открытия
//и работы с watermark
currDir, err := os.Getwd()
if err != nil {
return err
}
for i := range paths {
//применяем WaitGroup чтобы дождаться наложения watermark на все картинки
wg.Add(1)
//используем горутины чтобы конкурентно обработать наши картинки
go func(i int) {
defer wg.Done()
watermarkS := ""
//проверяем если все таки нет конкретного указания на необходимую watermark
// используем default
switch paths[i].Watermark {
case defaultWatermark:
watermarkS = defaultWatermark
case "":
watermarkS = defaultWatermark
default:
watermarkS = paths[i].Watermark
}
watermarkPath := currDir + "\" + watermarkS
//сама функция наложения watermark
funcErr := addWaterMark(paths[i].Path, watermarkPath)
//так как обработка конкурентна возьмем мьютекс для того чтобы если
//произошла хоть 1 ошибка то она точно вернется нам
if funcErr != nil {
mu.Lock()
if err == nil {
err = funcErr
}
mu.Unlock()
}
}(i)
}
wg.Wait()
return err
}
func addWaterMark(bgImg, watermark string) error {
/*наложение картинки на картину, так же здесь можно указывать
её прозрачность, угол поворота, пропорцию, положение по горизонтали/вертикали
*/
result, err := imagewatermark.ProcessImageWithWatermark(imagewatermark.WatermarkConfig{
InputPath: bgImg,
WatermarkPath: watermark,
OpacityAlpha: 0.5,
WatermarkWidthPercent: 40,
VerticalAlign: imagewatermark.VerticalRandom,
HorizontalAlign: imagewatermark.HorizontalRandom,
Spacing: 10,
RotationDegrees: 20,
})
if err != nil {
return err
}
//сохранение на диск
err = imaging.Save(result, bgImg)
return nil
}
Остаётся только изменить размер и сохранить в нужном формате наши обработанные изображения.
5. Resize и конвертация.
Теперь создадим файл resize.go и реализуем функцию изменения размера изображении и сохранения в нужном нам формате:
./internal/app/resize.go
package app
import (
"bytes"
"image/jpeg"
"image/png"
"io"
"log/slog"
"os"
"strconv"
sync "sync"
"github.com/chai2010/webp"
"github.com/nfnt/resize"
)
func resizeAndSave(paths []OriginalImage) []string {
var wg sync.WaitGroup
var mu sync.Mutex
//создаём результирующий слайс путей наших файлов чтобы потом отдать клиенту
uploadPaths := make([]string, 0)
for i := range paths {
// используем wg чтобы дождаться обработки всех картинок
wg.Add(1)
// используем горутины для распараллеливания процесса
go func(i int) {
defer wg.Done()
//основная функция изменения размера
uploadPath := resizeImage(paths[i])
if len(uploadPath) != 0 {
mu.Lock()
uploadPaths = append(uploadPaths, uploadPath...)
mu.Unlock()
} else {
mu.Lock()
uploadPaths = append(uploadPaths, paths[i].Path)
mu.Unlock()
}
}(i)
}
wg.Wait()
return uploadPaths
}
// наша основная функция на этом этапе обработки
func resizeImage(path OriginalImage) []string {
var mu sync.Mutex
// проверка что длина и ширина изображения имеются
for i := range path.Lenght {
if path.Lenght[i] == 0 || path.Width[i] == 0 {
return []string{}
}
}
//проверяем что у нас одинаковое количество
// параметров длины и ширины картинки для ее создания
if len(path.Lenght) != len(path.Width) {
return []string{}
}
//результирующий слайс путей наших файлов
var uploadPaths []string
switch path.Format {
case "png":
for i := range path.Lenght {
//открытие файла уже сжатого и с ватермаркой
imgIn, err := os.Open(path.Path)
if err != nil {
slog.Error("failed to open PNG file", "error", err.Error())
return []string{}
}
//его считывание
imgPng, err := png.Decode(imgIn)
if err != nil {
slog.Error("failed to decode PNG", "error", err.Error())
return []string{}
}
// и закрытие
err = imgIn.Close()
if err != nil {
slog.Error("failed to close PNG file", "error", err.Error())
return []string{}
}
// генерация нового изображения с измененным размером
imgPng = resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), imgPng, resize.Bilinear)
upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format
buf := new(bytes.Buffer)
// его запись в нужный нам формат
err = png.Encode(buf, imgPng)
if err != nil {
slog.Error("failed to encode PNG", "error", err.Error())
return []string{}
}
imgSave := buf.Bytes()
//сохранение нового изображения по сгенерированному пути
err = os.WriteFile(upPath, imgSave, 0666)
if err != nil {
slog.Error("failed to save PNG file", "error", err.Error())
return []string{}
}
// добавление полученного пути к общему слайсу
mu.Lock()
uploadPaths = append(uploadPaths, upPath)
mu.Unlock()
}
// по аналогии с кейсом png
case "jpg", "jpeg":
for i := range path.Lenght {
imgIn, err := os.Open(path.Path)
if err != nil {
slog.Error("failed to open JPEG file", "error", err.Error())
return []string{}
}
imgJpeg, err := jpeg.Decode(imgIn)
if err != nil {
slog.Error("failed to decode JPEG", "error", err.Error())
return []string{}
}
err = imgIn.Close()
if err != nil {
slog.Error("failed to close JPEG file", "error", err.Error())
return []string{}
}
imgJpeg = resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), imgJpeg, resize.Bilinear)
upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, imgJpeg, &jpeg.Options{Quality: 100})
if err != nil {
slog.Error("failed to encode JPEG", "error", err.Error())
return []string{}
}
imgSave := buf.Bytes()
err = os.WriteFile(upPath, imgSave, 0666)
if err != nil {
slog.Error("failed to save JPEG file", "error", err.Error())
return []string{}
}
mu.Lock()
uploadPaths = append(uploadPaths, upPath)
mu.Unlock()
}
// по аналогии с кейсом png
case "webp":
for i := range path.Lenght {
imgIn, err := os.Open(path.Path)
if err != nil {
slog.Error("failed to open WEBP file", "error", err.Error())
return []string{}
}
// Decode webp
mg, err := io.ReadAll(imgIn)
if err != nil {
slog.Error("failed to read WEBP file", "error", err.Error())
return []string{}
}
m, err := webp.DecodeRGB(mg)
if err != nil {
slog.Error("failed to decode WEBP", "error", err.Error())
return []string{}
}
err = imgIn.Close()
if err != nil {
slog.Error("failed to close WEBP file", "error", err.Error())
return []string{}
}
imgWebp := resize.Resize(uint(path.Lenght[i]), uint(path.Width[i]), m, resize.Bilinear)
upPath := path.Folder + path.UUID + "_" + strconv.FormatUint(uint64(path.Lenght[i]), 10) + "x" + strconv.FormatUint(uint64(path.Width[i]), 10) + "." + path.Format
imgSave, err := webp.EncodeRGB(imgWebp, 100)
if err != nil {
slog.Error("failed to encode WEBP", "error", err.Error())
return []string{}
}
err = os.WriteFile(upPath, imgSave, 0666)
if err != nil {
slog.Error("failed to save WEBP file", "error", err.Error())
return []string{}
}
mu.Lock()
uploadPaths = append(uploadPaths, upPath)
mu.Unlock()
}
default:
slog.Error("unknown file format, please provide a file with extension png,jpg,webp")
}
return uploadPaths
}
Теперь реализуем main.go в котором создадим и вызовем наш сервис обработки изображений.
./internal/cmd/main.go
package main
import (
"image-converter/internal/app"
"log/slog"
)
func main() {
imageServer := app.NewImageServer()
grpcServer := app.NewGrpcServer()
err := grpcServer.GrpcServeServer(imageServer, ":8086")
if err != nil {
slog.Warn("Server shutdown with error", "error", err.Error())
}
}
VI. CGO
Наше приложение полностью готово, но теперь нужно удостовериться, что у нас работает поддержка CGO. Для начала нужно убедиться что мы скачали и установили webp по ссылке официальная страница Google. Далее нам необходимо установить GCC, без него go не может распознать импортированные C файлы, скачиваем и устанавливаем официальные зеркала GNU. Теперь нужно проверить, что переменная 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 запрос

-
Выбираем метод grpc:

4.Уберите ползунок с reflection и укажите .proto файл

5.Выберите метод DownloadImage

6.В поле message заполните поле в соответствии с параметрами, например:
В поле image нужно скопировать текстовую строку ,что идёт после запятой, сгенерированную на шаге 1
7.Далее нужно запустить наше приложение и подключиться к нему с помощью кнопки →

В случае успеха должен появиться статус streaming
8.Отправляем наше сообщение/сообщения нажав на кнопку "Send message" один или несколько раз и завершаем нашу передачу сообщений с помощью кнопки → (если не нажать то приложение будет ожидать приёма сообщений). Результатом будет возврат путей наших обработанных сообщений

VIII. Заключение
Подведем итоги, мы создали сервис который:
-
Использует gRPC для эффективной передачи данных,
-
Поддерживает WebP, JPEG, PNG,
-
Безопасен в конкурентной среде,
-
Можно легко масштабировать.
В результате получился сервис обработки изображений. Этот подход применим не только к изображениям, но и к любым бинарным данным.
Исходный код доступен по ссылке
Спасибо за внимание!
Автор: artemkaVlg
