فیلدهای null شونده در Protobuf، با کاربرد در golang

در Protobuf نسخه ۳ تمامی فیلدها اختیاری هستند، اما این اختیاری بودن همه جا به این معنی نیست که مقدار اون فیلد nil ست میشه. تو این مطلب می‌خوایم حالتی رو بررسی کنیم که قصد ما ارسال مقدار nil برای یک فیلده و راه حل‌هایی که برای این مسئله وجود داره رو ببینیم. برای مثال این پیام ساده رو در نظر بگیرید:

message Measurement {
string id = 1;
string name = 2;
int32 value = 3;
}

زمان ایجاد و مقداردهی یک نمونه از این پیام می‌تونیم یک یا چند فیلد اون رو مقداردهی نکنیم:

m := &pb.Measurement {
name: "heat-sensor-1"
}

مسئله مقدار صفر در golang

حالتی رو تصور کنید که سرویس A پیام بالا رو به سرویس B مخابره می‌کنه. مقدار value برای نمونه ارسال شده چند خواهد بود؟ از اونجایی که متغیرهای مقداردهی نشده در گولنگ به صورت مقدار صفر در نظر گرفته میشن مقدار value صفر خواهد بود. یعنی پیامی که سرویس B دریافت می‌کنه به صورت زیره:

*pb.Measurement {
id: "",
name: "heat-sensor-1",
value: 0,
}

اما اگر value برای سرویس A واقعا نامعلوم باشه و ما بخوایم به سرویس B این رو بگیم که value نا معلومه چه راه‌هایی داریم؟

بسته‌بندی (wrapping) فیلد

تو این روش فیلد value رو با کمک یک message دیگر بسته‌بندی می‌کنیم:

با تعریف یک message جدید

با تعریف یک message جدید برای فیلد value:

message Heat {
int32 v = 1;
}

message Measurement {
string id = 1;
string name = 2;
Heat value = 3;
}

این امکان به وجود میاد که بتونیم برای value مقدار nil ارسال کنیم:

m := &pb.Measurement {
,name: "heat-sensor-1"
,value: nil
}

و پیامی که سرویس B دریافت می‌کنه:

*pb.Measurement {
id: "",
name: "heat-sensor-1",
value: nil,
}

با استفاده از wrappers.proto

به جای تعریف message جدید می‌تونیم از messageهای از پیش تعریف شده گوگل استفاده کنیم:

import "google/protobuf/wrappers.proto";

message Measurement {
string id = 1;
string name = 2;
google.protobuf.Int32Value value = 3;
}

در موارد خاص از Oneof هم می‌توان برای این هدف استفاده کرد که از حوصله این مطلب خارجه.

استفاده از کلیدواژه optional در Protobuf

بعد از بحث‌هایی نسبتا مفصل، تیم پروتوباف راضی به اضافه کردن کلیدواژه optional شد. این ویژگی به صورت آزمایشی از نسخه ۳.۱۲ پروتوباف در دسترسه. برای استفاده از این ویژگی کافیه قبل از فیلد مورد نظرم optional رو اضافه کنیم:

message Measurement {
string id = 1;
string name = 2;
optional int32 value = 3;
}

برای کامپایل نیازه که فلگ experimental_allow_proto3_optional ست بشه:

$ protoc test.proto --go_out=plugins=grpc:./ --experimental_allow_proto3_optional

در این حالت در فایل go جنریت شده value از نوع *int خواهد بود (به جای int). حالا به راحتی می‌تونیم مقدار value رو nil ارسال کنیم:

m := &pb.Measurement {
,name: "heat-sensor-1"
,value: nil
}

در این روش نیازی نداریم که از یک نوع message دیگر که عملا کاربردی جز بسته‌بندی داده برای ما ندارد استفاده کنیم.

رفرنس‌ها:

  1. How To Implement Field Presence for Proto3
  2. Application note: Field presence

آخرین مطالب

چطور در golang یک سرویس gRPC را در تست‌ها mock کنیم

قسمت دوم از سری مطالب «چطور یک سیستم مایکروسرویس روی کوبرنیتز بسازیم».

کد کامل این پست رو می‌تونید اینجا ببینید.

gRPC

ما تقریبا در تمامی سرویس‌های پلتفرم از gRPC برای ارتباط بین سرویس‌ها استفاده می‌کنیم. gRPC یک فریم‌ورک بهینه توسعه RPC است که قبلا قابلیت‌هاش در شرکت‌های بزرگ اثبات شده است.

در کنار کارایی gRPC مهمترین دلیلی که ما gRPC رو تقریبا همه جا استفاده می‌کنیم پروتکل انتقال پیام این فریم ورک یعنی Protocol Buffers است. حتی در سرویس‌هایی که از gRPC استفاده نمی‌کنیم، پیام‌هایی که به سمت سرویس‌های داخلی ارسال می‌کنیم را توسط پروتکل‌بافر (یا به اختصار پروتوباف) منتقل می‌کنیم.

اینجا از یک مثال ساده برای نمایش نحوه mock کردن gRPC استفاده می‌کنم.

ایجاد یک سرویس gRPC

در این مطلب ابتدا با تعریف یک فایل پروتوباف اون رو کامپایل و سپس یک کلاینت برای این سرویس پیاده‌سازی و در نهایت در تست‌ها این کلاینت gRPC رو mock می‌کنیم.

تعریف فایل Protobuf

این سرویس از دو rpc تشکیل شده که یکی با دریافت پیام PING پیام PONG رو در جواب بر می‌گردونه و دومی با دریافت هر پیامی، همون پیام رو در جواب بر می‌گردونه:

syntax = "proto3";

package pb;

service Signal {
  rpc Ping (PingRequest) returns (PingResponse) {}
  rpc Echo (EchoRequest) returns (EchoResponse) {}
}

option go_package = ".;pb";

message PingRequest {
  string message = 1;
}

message PingResponse {
  string message = 1;
}

message EchoRequest {
  string message = 1;
}

message EchoResponse {
  string message = 1;
}

کدهای پروتوباف رو کامپایل می‌کنیم:

protoc -I/opt/include -I/usr/local/include -Ipb \
		--go_out=./pb --go_opt=paths=source_relative \
    	--go-grpc_out=./pb --go-grpc_opt=paths=source_relative \
    	pb/*.proto

پیاده‌سازی کلاینت

کلاینت رو به عنوان دو http.HandlerFunc پیاده سازی می‌کنم:

package signal

import (
	"context"
	"encoding/json"
	"grpc-mock-example/pb"
	"log"
	"net/http"
	"time"

	"google.golang.org/grpc/status"

	"google.golang.org/grpc"
)

var (
	conn   *grpc.ClientConn
	client pb.SignalClient
)

func New(serverAddr string) pb.SignalClient {
	var opts []grpc.DialOption
	opts = append(opts, grpc.WithInsecure())
	opts = append(opts, grpc.WithBlock())
	conn, err := grpc.Dial(serverAddr, opts...)
	if err != nil {
		log.Fatalf("fail to dial: %v", err)
	}
	client = pb.NewSignalClient(conn)

	return client
}

func Ping(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	message := r.URL.Path[len("/ping/"):]
	ping, err := client.Ping(ctx, &pb.PingRequest{Message: message})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		e, ok := status.FromError(err)

		if ok {
			json.NewEncoder(w).Encode(e.Message())
			return
		}

		em := err.Error()
		log.Printf("%s", em)
		json.NewEncoder(w).Encode(em)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	json.NewEncoder(w).Encode(ping)
}

func Echo(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	message := r.URL.Path[len("/echo/"):]
	echo, err := client.Echo(ctx, &pb.EchoRequest{Message: message})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		e, ok := status.FromError(err)

		if ok {
			json.NewEncoder(w).Encode(e.Message())
			return
		}

		json.NewEncoder(w).Encode(err.Error())
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	json.NewEncoder(w).Encode(echo)
}

func Close() {
	conn.Close()
}

برای تولید mock ها از پکیج gomock استفاده می‌کنیم:

mockgen -source=pb/signal_grpc.pb.go -package=mocks SignalClient > ./mocks/signal_client_mock.go;

و بالاخره mock کردن gRPC در تست‌ها

package signal

import (
	"bytes"
	"encoding/json"
	"fmt"
	"grpc-mock-example/internal"
	"grpc-mock-example/mocks"
	"grpc-mock-example/pb"
	"net/http"
	"net/url"
	"reflect"
	"testing"

	"github.com/golang/mock/gomock"
	fuzz "github.com/google/gofuzz"
)

type pingRequest struct {
	message string
}

func (r *pingRequest) Matches(msg interface{}) bool {
	m, ok := msg.(*pb.PingRequest)
	if !ok {
		return false
	}

	return m.Message == r.message
}

func (r *pingRequest) String() string {
	return fmt.Sprintf("is %s %v", r.message, r.message)
}

type customResponseWriter struct {
	header     http.Header
	body       *bytes.Buffer
	statusCode int
}

func (c customResponseWriter) Header() http.Header {
	return c.header
}

func (c customResponseWriter) Write(body []byte) (int, error) {
	return c.body.Write(body)
}

func (c customResponseWriter) WriteHeader(statusCode int) {
	c.statusCode = statusCode
}

func TestPing(t *testing.T) {
	var msg string
	f := fuzz.New()
	ctrl := gomock.NewController(t)
	signalClient := mocks.NewMockSignalClient(ctrl)

	f.Fuzz(&msg)

	signalClient.
		EXPECT().
		Ping(gomock.Any(), &pingRequest{message: "Ping"}, gomock.Any()).
		Return(nil, internal.ErrInvalidPing).
		AnyTimes()

	signalClient.
		EXPECT().
		Ping(gomock.Any(), &pingRequest{message: "PING"}, gomock.Any()).
		Return(&pb.PingResponse{Message: "PONG"}, nil).
		AnyTimes()

	client = signalClient

	type args struct {
		w customResponseWriter
		r *http.Request
	}
	tests := []struct {
		name    string
		args    args
		want    string
		wantErr bool
	}{
		{
			name: "Ping",
			args: args{
				w: customResponseWriter{
					header: http.Header{},
					body:   &bytes.Buffer{},
				},
				r: &http.Request{
					Method: "GET",
					URL: &url.URL{
						Path: "/ping/Ping",
					},
				},
			},
			want: "\"invalid ping message\"\n",
		},
		{
			name: "PING",
			args: args{
				w: customResponseWriter{
					header: http.Header{},
					body:   &bytes.Buffer{},
				},
				r: &http.Request{
					Method: "GET",
					URL: &url.URL{
						Path: "/ping/PING",
					},
				},
			},
			want: "{\"message\":\"PONG\"}\n",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			Ping(tt.args.w, tt.args.r)

			got := tt.args.w.body.String()
			if !reflect.DeepEqual(tt.args.w.body.String(), tt.want) {
				t.Errorf("TestPing() = %v, want %v", got, tt.want)
			}
		})
	}
}

کد کامل این پست رو می‌تونید اینجا ببینید.

خروج از نسخه موبایل