Golang - Unit testing for GRPC function and Golang build proxy

Posted by Jimmy's Blog on March 26, 2021

前言

距離上一篇拖了快一個月XD,之前因為工作較忙而且準備英文考試比較沒有時間來寫 blog,最近趁著空檔來把上次沒完成的把它寫完。上篇說到利用 shared library 可以將串接 GRPC service 的 code 統一包裝起來,這樣子維護以及可用性會比較好,尤其當你是多人開發遇到這個情況時,感受會更為顯著,且整體 team 間彼此的 code 重複性不會太多。那麼同仁們在使用時遇到了一些問題,後來筆者發現其實有寫 Unit testing 的話便可以盡早發現這些問題,因此這篇筆者將會分享同仁們使用時遇到了哪些問題,筆者該怎麼去測 GRPC function。

最後,同場加映一下筆者為了加速公司 CI/CD 流程,為公司引入了 Golang proxy 的機制,這邊也分享一下。

Unit testing

延續上一篇的 GRPC service 的 proto,我們今天要如何來對每個 wrapper 對應到的 GRPC function 去做測試呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";

service systemMessage {

    rpc getInfoMsg (getRequest) returns (getResponse) {}

    rpc getWarningMsg (getRequest) returns (getResponse) {}

    rpc postSysInfo (addRequest) returns (addResponse) {}

    rpc postSysWarning (addRequest) returns (addResponse) {}

    ...
}

舉例來說,根據上篇文章假設筆者用 function: Info 去包裝 GRPC function: postSysInfo,用途是要送有關 information 的 message。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//Info is to send messages to systemmessage, assign title and message in parameter field
func (s *SystemMsg) Info(title string, message string) {
	s.Mutex.Lock()
	defer s.Mutex.Unlock()
	if s.Conn == nil {
		return
	}
	if s.Client != nil {
		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		_, err := s.Client.PostSysInfo(ctx, &spec.PostSysInfoRequest{
			Module:  s.Module,
			Title:   title,
			Message: message,
		})
		//if send message failed, print the error msg on console
		if err != nil {
			log.Printf("Send system message failed, err: %s\n", err)
		}
	}
}

因此,筆者這邊會針對包裝過起來的 function 去做 unit testing

Mock GRPC server

首先,我們先將我們要測的 GRPC server 做一個假的起來。所以編寫 mock_sysmsg.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package sysmsg

//This is a mock GRPC server for systemwarning.
//Each rpc function just return 0 for status code and write request string into buffer.
import (
	"bytes"
	"context"
	"fmt"
	"net"
	spec "sharedlib/pb/systemwarning"
	"sync"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

//MockServer for testing
var MockServer *MockmessageServer

//define grpc server and run once function
var grpcServer *grpc.Server
var grpcSync sync.Once

//MockmessageServer define
type MockmessageServer struct {
	bufString *bytes.Buffer
}

//PostWarning define
func (ms *MockmessageServer) PostSysInfo(ctx context.Context, req *spec.AddRequest) (*spec.GeneralResponse, error) {
	response := &spec.GeneralResponse{
		StatusCode: 0,
		Message:    "success",
	}
	ms.bufString.WriteString(req.String())
	return response, nil
}

func initialServer() {
	grpcSync.Do(func() {
		grpcServer = grpc.NewServer()
		fmt.Println("Start insecured gRPC server")
	})
}

//StopGRPCServer gracefully
func StopGRPCServer() {
	grpcServer.GracefulStop()
}

//StartGRPCServer start grpc server on target port
func StartGRPCServer(port string) {
	initialServer()
	lis, err := net.Listen("tcp", ":"+port)
	if err != nil {
		fmt.Printf("failed to listen: %v\n", err)
		return
	}
	var buf bytes.Buffer
	grpcServer = grpc.NewServer()
	MockServer = &MockmessageServer{
		bufString: &buf,
	}
	spec.RegisterSystemWarningServer(grpcServer, MockServer)
	reflection.Register(grpcServer)
	if err := grpcServer.Serve(lis); err != nil {
		fmt.Printf("failed to serve: %v\n", err)
		return
	}
}

這邊筆者只列出 PostSysInfo來實作,其餘讀者可以自行補充。

有寫過 GRPC 的朋友應該都知道,我們 backup-end 會需要去實作 proto 裡面所定義出來的 GRPC function。那其實對於我們 wrapper 的 function 來說,back-end 裡面的實際邏輯對我們來說是甚麼我們其實不需要知道,我們就可以來做 testing 了! 這個概念是甚麼呢? 假設這個 GRPC server 是另外一個同仁在實作,但是當他還沒完成的時候,我們就只能乾等直到他寫好之後我們才能跟他串起來嗎? 當然不是,我們可以利用我們自己 mock 起來的 server 就能測到一些 error handling 的機制。

Single testing of a GRPC function

以剛剛上面的 mock server 為例子,筆者將 PostSysInfo 簡單地實作成,把收到的 request 塞進一個 buffer 裡面,再回傳給 client。所以 testing code 來測包裝 PostSysInfo 的 Info function 就會像是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package sysmsg

import (
	"fmt"
	"strings"
	"testing"
	"time"
)

var sysMsg *SystemMsg

func initMockServer() {
	go StartGRPCServer("9876")
	time.Sleep(3 * time.Second)
	sysMsg = NewSystemMessageSender("test", "localhost:9876")
}

func TestSendSystemInfo(t *testing.T) {
	initMockServer()
	message := "This message is an info message"
	sysMsg.Info("testing", message)
	res := MockServer.bufString.String()
	if !strings.Contains(res, message) {
		t.Fail()
	}
	defer teardown()
}

func teardown() {
	sysMsg.Close()
	StopGRPCServer()
}

從 buffer 讀取字串,看是否 message 符合預期的字串。

When GRPC server is dead

前面有提到同仁遇到的問題,那就是當 GRPC server 不通或是他掛掉的時候,筆者這邊 wrapper function 沒有處理到這塊。那麼如果筆者有寫下面這個 testing 的話就會抓到這個情況:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestSendFailed(t *testing.T) {
	initMockServer()
	//send a message
	message := "This message is a message"
	sysMsg.Info("testing", message)
	res := MockServer.bufString.String()
	if !strings.Contains(res, message) {
		t.Fail()
	}
	//stop server
	StopGRPCServer()
	//try to send a message again
	message = "This message should not exists"
	sysMsg.Info("testing", message)
	res = MockServer.bufString.String()
	if strings.Contains(res, message) {
		t.Fail()
	}
	defer teardown()
}

故意在中間將 mock server 給關掉,試著模擬連不到的情況。以之前 wrapper 的實作方式,連不到的時候將會卡住在要把 message 送出去的部分,因為 GRPC 有自己嘗試重連的機制,那筆者這邊的 context 又沒有設 timeout 的話就會永遠卡住。但是設 timeout 的話,比如說 5 秒,那當連不到的時候又會每次都卡 5 秒才會繼續往下 go。可能會導致你的 module 有使用到這個 function 的 code 都會被拖著 5 秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
if s.Client != nil {
		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		_, err := s.Client.PostSysInfo(ctx, &spec.PostSysInfoRequest{
			Module:  s.Module,
			Title:   title,
			Message: message,
		})
		//if send message failed, print the error msg on console
		if err != nil {
			log.Printf("Send system message failed, err: %s\n", err)
		}
	}

因此,筆者這邊詢問了一下 google 大叔的建議,找了一下有沒有關於可以 check GRPC connection 的方式。答案是有的,可以使用 GetState,但是這些 function 在官方文件上的註解有寫說:

1
2
3
Experimental

Notice: This API is EXPERIMENTAL and may be changed or removed in a later release

但是筆者使用上其實是沒有問題的,GetState 可以用來 check 目前 connection 的狀態,就可以先檢查連接狀態來決定是否要繼續送 message。

1
2
3
4
5
state := s.Conn.GetState().String()
if state != "READY" && state != "IDLE" {
	log.Printf("Can not connect to sysmsg module, sysmsg module state is %s\n", state)
	return
}

若不是 READY 或是 IDLE,則直接寫 log 並且直接 return。這樣一來,當 GRPC server 連不到,就不會卡住並且能夠從 log 直接判斷連接中斷的部分。

小結

Unit testing 很重要,可以幫助你抓出一些非預期性的情況,模擬這些情況並且處理好 error handling。除此之外,也不會與別人有工作上的 dependency,整合可以更為快速。

Golang proxy

最後,分享一下,筆者幫助公司引進了在 build code 時,能夠節省大半時間的 golang proxy

1
2
3
export athens_storage=~/athens-storage 
mkdir -p $athens_storage 
docker run -d -v $athens_storage:/var/lib/athens \ -e athens_disk_storage_root=/var/lib/athens \ -e athens_storage_type=disk \ --name athens-proxy \ --restart always \ -p 3000:3000 \ gomods/athens:latest

各位夥伴也可以去 google 一下有關 golang build proxy 的關鍵字。簡單來說,在 build machine 去帶起來一個 container,它會去儲存你要 build 的 project 所需要的套件,那麼下一次要再重新 build code 的時候,就不會去網路上面拉,會在此 proxy 拉。除非你更新了所使用的套件版本或是有新套件的使用,他才會去網路上面尋找。

筆者公司是使用 dockerfile 來 build golang 的 project。如以下範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM golang:1.12-alpine AS go-builder

LABEL stage=build

ENV GO111MODULE on

ARG GOLANG_PROXY_IP
ENV GOPROXY=http://${GOLANG_PROXY_IP}:3000

WORKDIR /go/src/kubeshell

RUN apk add git && \
    cd /go/src/kubeshell && \
    CGO_ENABLE=0 GOOS=linux GOARCH=amd64 go build -o app main.go

...

將環境變數 GOPROXY 指向到你的 build machine,也就是你上面 container 帶起來的所在機器 IP,不一定要 3000 port,在上方的 docker run command 中去改變即可。印象中,引進此機制後,從原本快一個小時的 build job,縮短到 20 多分鐘,當你 project 越多時,效果一定越為顯著喔!

Contact me

有任何疑問或是建議歡迎聯絡 Jimmy!

Gmail: jimmyw86878@gmail.com