golang

Сериализация данных в Golang с Protobuf

  • суббота, 13 января 2024 г. в 00:00:18
https://habr.com/ru/companies/otus/articles/784732/

Привет, Хабр!

Protobuf, или Protocol Buffers, это бинарный формат сериализации, разработанный в Google для эффективного обмена данными между сервисами. Это как JSON, только компактнее, быстрее и типизированнее. Если JSON был вашим первым крашем в мире сериализации, то Protobuf – это тот, с кем вы хотите серьёзных отношений.

Если вы хотите, чтобы ваши приложения общались между сервисами с молниеносной скоростью и минимальными задержками, тогда Protobuf — твой хороший выбор.

Установка

Шаг 1: установка Protobuf Compiler

Первым делом, вам нужно скачать Protobuf Compiler. Загляните на GitHub страницу Protobuf и выберите версию, подходящую для вашей ОС.

После скачивания распакуйте содержимое и добавьте путь к исполняемому файлу protoc в вашу системную переменную PATH. В Unix это что-то вроде export PATH=$PATH:/path/to/protoc.

Шаг 2: установка Protobuf Plugin

Запустите go get -u google.golang.org/protobuf/cmd/protoc-gen-go для установки плагина protoc для Go. Этот плагин нужен, чтобы превращать ваши .proto файлы в go-файлы.

Введите protoc --version в консоли. Если вы видите версию, вы успешно установили компилятор!

Cтруктура .proto файлов

Прежде всего, .proto файл - это схема данных для Protobuf. Это место, где вы описываете структуру данных, которую хотите сериализовать/десериализовать.

Все начинается с указания версии синтаксиса. Например, syntax = "proto3";. Это говорит компилятору, какие правила использовать при анализе содержимого. Затем идет объявление пакета package mypackage;. Это помогает избежать конфликтов имен и организовать код.

Сердце .proto файла - это объявления сообщений. Каждое сообщение - это структура данных, которую вы хотите сериализовать.

Сообщения определяются с использованием синтаксиса message MyMessage {}. Внутри фигурных скобок вы описываете поля данных. Поля могут быть стандартными типами данных, такими как int32, float, double, string или bool. Также могут быть пользовательские типы или другие сообщения.

В Protobuf есть три типа правил для полей: singular для одиночных значений, repeated для массивов значений и map для ассоциативных массивов. Каждому полю присваивается уникальный номер. Эти номера используются в бинарном представлении и очень важны для совместимости данных

Начинайте номера полей с 1, и будьте осторожны, не перепрыгивая через десятки и сотни - это может быть полезно для будущих версий вашего протокола. Если поле больше не нужно, пометьте его как reserved.

Предположим, вы хотите сериализовать информацию о техническом обзоре, который включает в себя разделы, заголовки и содержание:

syntax = "proto"

package techreview;

// Объявляем перечисление для различных типов контента
enum ContentType {
  UNKNOWN = 0;
  INTRODUCTION = 1;
  TECHNICAL_OVERVIEW = 2;
  BEST_PRACTICES = 3;
  CONCLUSION = 4;
}

// Определяем структуру для раздела обзора
message Section {
  string title = 1; // Заголовок раздела
  string content = 2; // Содержание раздела
  ContentType type = 3; // Тип содержимого (введение, обзор и т.д.)
}

// Определяем основную структуру для всего технического обзора
message TechnicalReview {
  repeated Section sections = 1; // Массив разделов обзора
}

Enums, Maps

Создаем Enum в .proto:

enum Status {
  UNKNOWN = 0;
  RUNNING = 1;
  STOPPED = 2;
}

После компиляции .proto файла, мы получаем четко определенный тип Status, который можно использовать прямо в коде.

var currentStatus Status = Status_RUNNING

Maps позволяет создавать структурированные коллекции пар ключ-значение.

Объявление Map в .proto:

message Environment {
  map<string, string> variables = 1;
}

После компиляции .proto файла, мы получаем map, который можно легко использовать.

env := Environment{
    Variables: map[string]string{"HOME": "/home/user", "PATH": "/usr/bin"},
}

Прощай, массивы пар или сложные структуры для простых задач.

Oneof: когда одного поля мало

oneof - это инструмент в Protobuf для работы с различными структурами в одном поле.

В .proto файле:

message Command {
  oneof command_type {
    string text = 1;
    int32 number = 2;
  }
}

После компиляции .proto, можно управлять этими полями как обычными структурами в Go.

cmd := Command{
    CommandType: &Command_Text{Text: "Hello, Protobuf!"},
}

Как автоматически генерировать код из .proto файлов

Допустим, у нас есть следующий .proto файл, который описывает структуру данных для пользователя:

syntax = "proto ";

package user;

message User {
  string name = 1;
  int32 age = 2;
  string email = 3;
} 

Для генерации кода можно юзать команду protoc:

protoc --go_out=. user.proto

После выполнения этой команды в той же директории появится файл user.pb.go, содержащий код на Go для ваших структур данных.

Как можно использовать сгенерированный код на Go:

package main

import (
    "fmt"
    "log"

    "github.com/golang/protobuf/proto"
    "path/to/generated/user"  // Импорт сгенерированного кода
)

func main() {
    newUser := &user.User{
        Name:  "Alice",
        Age:   30,
        Email: "alice@example.com",
    }

    // сериализация данных
    data, err := proto.Marshal(newUser)
    if err != nil {
        log.Fatal("Marshaling error: ", err)
    }

    // десериализация данных
    newUser2 := &user.User{}
    err = proto.Unmarshal(data, newUser2)
    if err != nil {
        log.Fatal("Unmarshaling error: ", err)
    }

    fmt.Println(newUser2.GetName(), newUser2.GetAge(), newUser2.GetEmail())
}

Для добавления дополнительного функционала можно использовать плагины. Например, protoc-gen-go-grpc для создания gRPC сервера и клиента.

gRPC в protobuf

Установим нужные пакеты:

go get -u google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go

Для начала определим наш gRPC сервис и сообщения в .proto файле:

syntax = "proto3";

package example;

// Определение сервиса
service Greeter {
  // Определение метода сервиса
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// Определение сообщения, используемого для запроса
message HelloRequest {
  string name = 1;
}

// Определение сообщения, используемого для ответа
message HelloReply {
  string message = 1;
}

Следующий шаг - сгенерировать Golang код из нашего .proto файла:

protoc --go_out=plugins=grpc:. *.proto

Реализуем сервер:

package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"
    pb "path/to/your/protos/example"
)

// server is used to implement example.GreeterServer.
type server struct {
    pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v", in.GetName())
    return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

И наконец, реализуем клиент для общения с нашим сервером:

package main

import (
    "context"
    "log"
    "os"
    "time"

    "google.golang.org/grpc"
    pb "path/to/your/protos/example"
)

const (
    address     = "localhost:50051"
    defaultName = "world"
)

func main() {
    // Установка соединения с сервером
    conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewGreeterClient(conn)

    // Имя пользователя для приветствия
    name := defaultName
    if len(os.Args) > 1 {
        name = os.Args[1]
    }

    // Контекст для отмены запроса по истечении таймаута
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    // Вызов метода SayHello на сервере
    r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
    if err != nil {
        log.Fatalf("could not greet: %v", err)
    }
    log.Printf("Greeting: %s", r.GetMessage())
}

Микросервисы

Предположим, у нас есть микросервис для управления пользователями:

syntax = "proto3";

package user;

// Сервис для работы с пользователями
service UserService {
  // Запрос на получение информации о пользователе
  rpc GetUser (UserRequest) returns (UserResponse) {}
}

// Запрос на получение информации о пользователе
message UserRequest {
  int64 id = 1;
}

// Ответ с информацией о пользователе
message UserResponse {
  int64 id = 1;
  string name = 2;
  string email = 3;
}

Допустим, мы сгенерировали Golang код из нашего файла. Напишем сервер:

package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"
    pb "path/to/your/protos/user"
)

type server struct {
    pb.UnimplementedUserServiceServer
}

func (s *server) GetUser(ctx context.Context, in *pb.UserRequest) (*pb.UserResponse, error) {
    log.Printf("Received: %v", in.GetId())
    // здест к примеру логика получения пользователя из базы данных или другого сервиса
    return &pb.UserResponse{Id: in.GetId(), Name: "John", Email: "john@example.com"}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterUserServiceServer(s, &server{})
    log.Printf("Server listening at %v", lis.Addr())
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Создадим клиент, который будет общаться с нашим сервером:

package main

import (
    "context"
    "log"
    "os"
    "time"

    "google.golang.org/grpc"
    pb "path/to/your/protos/user"
)

const (
    address     = "localhost:50051"
)

func main() {
    // установка соединения с сервером
    conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewUserServiceClient(conn)

    // ус тановим контекст с таймаутом
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    // запрс на получение пользователя
    r, err := c.GetUser(ctx, &pb.UserRequest{Id: 123})
    if err != nil {
        log.Fatalf("could not greet: %v", err)
    }
    log.Printf("User: %s", r.GetName())
}

Protobuf это компактный, бинарный формат, который уменьшает размер передаваемых данных и ускоряет их обработку, также он совместим со многими ЯПами.

В завершение хочу порекомендовать бесплатный вебинар, где вы узнаете, какие конкретные компетенции необходимы Golang-инженерам на разных этапах развития карьеры. Регистрация доступна по ссылке.