1. Giới thiệu

gRPC được phát triển bởi Google, là một khung RPC (Remote Procedure Calls) hiện đại hiệu suất cao, được sử dụng rộng rãi trong môi trường microservices hiện nay. gRPC sử dụng protobufs làm định dạng trao đổi thông điệp cơ bản và tận dụng HTTP/2, cho phép các tính năng như đa luồng và truyền dữ liệu hai chiều. Mặc dù gRPC tương thích với nhiều ngôn ngữ lập trình khác nhau, Go đặc biệt phổ biến và được công nhận là lựa chọn tiện lợi và thông dụng nhất.

Bài viết này sẽ bắt đầu bằng việc khám phá các nguyên tắc cơ bản của gRPC, hiểu rõ mục đích và các trường hợp sử dụng của nó. Sau đó, sẽ xem cách mà Protocol Buffers hoạt động và cách viết các định nghĩa thông điệp và tạo mã cho các định nghĩa đó. Tiếp theo sẽ đi sâu vào việc tạo các định nghĩa dịch vụ gRPC và tiếp tục viết một API microservice đơn giản.

Chỉ tạo một microservice là chưa đủ; lý tưởng là muốn sử dụng dịch vụ đó. Để làm điều này, cần tạo một dịch vụ API gateway dựa trên REST sử dụng gRPC-gateway có thể gọi các phương thức trên dịch vụ gRPC mới được triển khai và trả về các phản hồi cho người dùng dưới dạng JSON.

Trước khi đi sâu vào hướng dẫn, hãy nhanh chóng thảo luận về RPC và IDL (Ngôn ngữ Định nghĩa Giao diện) là gì trước khi xem chi tiết cụ thể của gRPC.

Trong kiến trúc dựa trên REST thông thường, một máy chủ HTTP đăng ký các điểm cuối để xác định các bộ xử lý nào sẽ được gọi dựa trên đường dẫn URL, động từ HTTP (GET, POST, PUT) và các tham số đường dẫn. Ngược lại, RPC cung cấp một mức trừu tượng cao hơn, cho phép các khách hàng gọi các phương thức từ xa trên máy chủ HTTP như thể chúng là các cuộc gọi phương thức cục bộ.

RPC thường dựa vào một IDL, một đặc tả nêu rõ cấu trúc và giao thức truyền thông. Trong ngữ cảnh RPC, các định dạng tải trọng và định nghĩa dịch vụ được xác định bằng các ngôn ngữ có thể tuần tự hóa như Protobuf, Apache Thrift, Apache Avro và những ngôn ngữ khác. Các định nghĩa này sau đó được sử dụng để tạo ra các triển khai tương ứng cho một ngôn ngữ lập trình đa dụng cụ thể, như Go, Java, Python, v.v. Những triển khai này sau đó có thể được tích hợp vào một khung RPC như gRPC, cho phép chúng ta tạo ra một máy chủ web và một client tương ứng có khả năng giao tiếp với máy chủ web đã tạo.

Biểu đồ dưới đây cung cấp một ý tưởng tổng quan về những gì một khung RPC thực hiện:

1UvSsfyD4FYaZiFDgy0MoGv6mYm40t9AJ

Mặc dù điều này có vẻ như là phép thuật, nhưng thực tế, giao tiếp diễn ra qua HTTP và được trừu tượng hóa khỏi người dùng như bên dưới:

1FMXNSZO0l1v_73Jh3BUjwQvKfArtXNP5

2. gRPC là một framework

gRPC là một framework RPC như đã mô tả ở trên, và hiện tại nó là một trong những framework RPC được sử dụng rộng rãi nhất. Một số thông tin cơ bản về gRPC bao gồm:

  • Nó sử dụng Protocol Buffers làm IDL để viết các định nghĩa thông điệp và dịch vụ.
  • Giao tiếp cơ bản được thực hiện qua HTTP/2, hỗ trợ việc đa luồng nhiều yêu cầu và phản hồi trên một kết nối duy nhất. Điều này giảm độ trễ so với nhiều kết nối HTTP/1.1.
  • Nó cung cấp một cách chuẩn hóa để xử lý lỗi với các mã trạng thái và thông điệp chi tiết, giúp các nhà phát triển dễ dàng hiểu và xử lý các sự cố.
  • Nó hỗ trợ nhiều ngôn ngữ khác nhau như Go, Java, Node, C++ và nhiều ngôn ngữ khác.

3. Protocol Buffers

Protocol Buffers là một định dạng trao đổi dữ liệu tương tự như JSON. Cả Protobuf và JSON đều dùng để tuần tự hóa, tuy nhiên, điểm khác biệt chính là Protobuf là định dạng trao đổi dữ liệu nhị phân trong khi JSON lưu trữ dữ liệu ở định dạng văn bản dễ đọc hơn.

Theo định nghĩa của Google: Bạn có thể cập nhật cấu trúc dữ liệu của mình mà không làm hỏng các chương trình đã triển khai được biên dịch theo định dạng cấu trúc dữ liệu cũ. Thú vị phải không? Đúng vậy, chúng ta sẽ thấy cách hoạt động của nó.

Protocol Buffers cho phép chúng ta xác định hợp đồng dữ liệu giữa nhiều hệ thống. Khi một tệp proto-buff đã được định nghĩa, chúng ta có thể biên dịch nó sang một ngôn ngữ lập trình mục tiêu. Kết quả của quá trình biên dịch sẽ là các lớp và hàm của ngôn ngữ lập trình mục tiêu. Trong Go, Protocol Buffers có thể được vận chuyển qua các giao thức khác nhau, như HTTP/2 và Advanced Message Queuing Protocol (AMQP).

1VgJVBkmEwnPFRaUG9USEUcmT6s_jzlHJ

4. Protocol Buffer Language

Một protocol buffer là một tệp, khi biên dịch sẽ tạo ra một tệp có thể truy cập bởi ngôn ngữ lập trình mục tiêu. Trong Go, đó sẽ là một tệp .go, được biến dạng thành các cấu trúc (structs).

Hãy viết một message (thông điệp) đơn giản trong protobuf:

syntax 'proto3'

message UserInterace {
  int      index         = 1;
  string   firstName     = 2;
  string   lastName      = 3;
}

Ở đây chúng ta chỉ định một loại message gọi là UserInterface. Nếu hiểu theo cách viết JSON thì sẽ như sau:

{
  "index": 0,
  "firstName": "John",
  "lastName": "Doe"
}

Các tên trường đã được thay đổi để tuân thủ theo hướng dẫn về kiểu JSON, nhưng bản chất và cấu trúc vẫn giống nhau.

Những số thứ tự (1, 2, 3) trong tệp protobuf là các thẻ thứ tự được sử dụng để tuần tự hóa và giải tuần tự hóa protobuf giữa hai hệ thống. Nó cho hệ thống biết để viết dữ liệu theo thứ tự cụ thể đó với các kiểu dữ liệu chỉ định. Vì vậy, khi protobuf này được biên dịch cho ngôn ngữ đích là Go, nó sẽ trở thành một cấu trúc với các giá trị mặc định rỗng.

Các loại dữ liệu khác nhau được sử dụng trong protobuf bao gồm:

  • Scalar (như số nguyên, số thực, boolean)
  • Enumerations (các enum)
  • Nested (các cấu trúc lồng nhau)

5. Use case example

Chúng ta lấy ví dụ về service tạo hoá đơn

5.1 Định nghĩa message

Để gửi các request đến server, chúng ta phải định nghĩa message trước.
Chúng ta sẽ sử dụng một tệp protocol buffer để định nghĩa các thông điệp và dịch vụ.
Hãy bắt đầu với yêu cầu (những gì chúng ta muốn gửi đến dịch vụ):

syntax = "proto3";
option go_package = "myPkgName";


message CreateRequest {
  string from = 1;
  string to = 2;
  Amount total = 3;
}

message Amount {
  string currency = 1;
  int64 amount = 2;
}

Đoạn code trên là định nghĩa một message CreateRequest với 3 trường: from, tototal.
Bạn có thể nhận thấy rằng đối với mỗi trường, chúng ta định nghĩa kiểu dữ liệu. Kiểu dữ liệu cho trường totalAmount, đó là một thông điệp khác.

Kế tiếp, định nghĩa response:

// ...

message CreateResponse {
  bytes pdf = 1;
}

Định nghĩa service:

// ..
service Invoicer {
  // Create an invoice
  rpc Create (CreateRequest) returns (CreateResponse) {}
}

Cuối cùng chúng ta được 1 file hoàn chỉnh như sau:

// file: invoicer.proto
syntax = "proto3";
option go_package = "myPkgName";

message Amount {
  string currency = 1;
  int64 amount = 2;
}

message CreateRequest {
  string from = 1;
  string to = 2;
  Amount total = 3;
}

message CreateResponse {
  bytes pdf = 1;
}

service Invoicer {
  // Create an invoice
  rpc Create (CreateRequest) returns (CreateResponse) {}
}

5.2 Chuẩn bị môi trường

Cài đặt protoc (trình biên dịch)

Linux:

apt install -y protobuf-compiler

MacOs:

brew install protobuf

Kiểm tra version:

protoc --version

Cài đặt protoc-gen-go (trình tạo code tự động)

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

5.3 Generate Go code

Chúng ta sẽ chạy protoc để sinh mã:

Thay đổi trong lệnh sau các giá trị sau đây:

  • myPkgName: nên là đường dẫn đến thư mục mà bạn muốn đặt mã được sinh ra
  • invoicer.proto: nên được thay thế bằng đường dẫn đến tệp proto của bạn
protoc --go_out=myPkgName --go_opt=paths=source_relative \
    --go-grpc_out=myPkgName --go-grpc_opt=paths=source_relative \
    invoicer.proto

Sau khi bạn run command trên, 2 files sẽ được tạo:

  • invoicer.pb.go: là code cần thiết cho encode/decode messages
  • invoicer_grpc.pb.go: là code cần thiết để chạy một gRPC server/client

5.4 Build một server

Đầu tiên, cần cài đặt thư viện cho gRPC vào module của bạn:

go get -u google.golang.org/grpc

Tạo một server type:

// should implement the interface myPkgName.InvoicerServer
type myGRPCServer struct {
 	// type embedded to comply with Google lib
 	myPkgName.UnimplementedInvoicerServer
}

func (m *myGRPCServer) Create(ctx context.Context, request *myPkgName.CreateRequest) (*myPkgName.CreateResponse, error) {
 	log.Println("Create called")
 	return &myPkgName.CreateResponse{Pdf: []byte("TODO")}, nil
}

5.5 Khởi tạo server:

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

Khởi tạo kết nối TCP với net.Listen("tcp", ":3333")
Sau đó, tạo một máy chủ gRPC mới với grpc.NewServer()
Tạo một biến mới myInvoicerServer thuộc loại *myGRPCServer
Tiếp theo, đăng ký dịch vụ của mình trên server này với myPkgName.RegisterInvoicerServer(s, myInvoicerServer)
Bước cuối cùng là chấp nhận các kết nối đến bằng cách gọi Serve trên server gRPC

Đây là code hoàn chỉnh:

package main

import (
  "context"
  "depositv3/p/19_grpc_server/myPkgName"
  "log"
  "net"

  "google.golang.org/grpc"
)

// should implement the interface myPkgName.InvoicerServer
type myGRPCServer struct {
  myPkgName.UnimplementedInvoicerServer
}

func (m *myGRPCServer) Create(ctx context.Context, request *myPkgName.CreateRequest) (*myPkgName.CreateResponse, error) {
  log.Println("Create called")
  return &myPkgName.CreateResponse{Pdf: []byte("TODO")}, nil
}

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

5.6 Run test

Có nhiều tùy chọn để gửi yêu cầu đến server:

  • Build một gRPC client bằng Go
  • Sử dụng một client: BloomRPC (có GUI, giống Postman)
  • Sử dụng grpcurl

Ở đây sẽ test bằng BloomPRC

112o72hRF-7JpBRpfmiGHZSoSEUB2AQ5n

Tham khảo