牌類遊戲使用微服務重構筆記(六): protobuf爬坑

段鵬舉發表於2019-04-15

前言

Protocol Buffer是Google的語言中立的,平臺中立的,可擴充套件機制的,用於序列化結構化資料 - 對比XML,但更小,更快,更簡單。您可以定義資料的結構化,然後可以使用特殊生成的原始碼輕鬆地在各種資料流中使用各種語言編寫和讀取結構化資料。

主要有點有:

  • 1.protoBuf在Google內部長期使用,產品穩定成熟,很多商業的專案都選擇使用

  • 2.跨語言,它支援Java、C++、Python、ObJect-c、C#、Go等語言

  • 3.protoBuf編碼後訊息更小、有利於儲存傳輸

  • 4.編碼和解碼的效率非常之高

  • 5.支援不同版本的協議向前相容

我使用的proto版本是protobuf3,關於proto的學習網路上已有許多優秀的文章,在這不再贅述。 本文只介紹我在使用protobuf過程中收穫的經驗和遇到的坑以及如何解決的。

protobuf 語法指南

單獨一個專案?

如果要在多個專案中共用proto檔案,最好的解決辦法是單獨拉出來一個git專案來管理proto檔案。在筆者的專案中,有服務端、遊戲客戶端、web客戶端共用proto專案

檔案結構劃分

多個專案共用proto,每個專案對proto檔案的需求可能不一致,服務端可能需要全部的proto定義;遊戲客戶端和web客戶端根據業務不同,可能只需要其中的一部分,或者對於關於servicegrpc的定義,客戶端一般都是不需要的(起碼我們的專案中不需要)。

將proto檔案進行合理的拆分,將會大大減小客戶端編譯後的proto檔案體積。在我們的專案中,在沒有劃分之前,客戶端檔案有1M多,劃分之後只有300K左右

筆者的思路是:把一個模組裡的proto劃分為xx.basic.proto、xx.service.proto、xx.api.proto, 其中basic.proto 定義一些基本資料結構,service.proto 定義服務端服務,api.proto 定義http api服務, service和api都引用basic , 例如:

牌類遊戲使用微服務重構筆記(六): protobuf爬坑

test.basic.proto

syntax = "proto3";

package test.basic;
option go_package = "xxxxxx/.go/test";

message Message {
    int32 i = 1;
}
複製程式碼

test.service.proto

syntax = "proto3";

package test.service;
option go_package = "xxxxxx/.go/test";

import "test/test.basic.proto";


service Test {
    rpc Hello(HelloRequest) returns(HelloResponse) {}
}

message HelloRequest {

}

message HelloResponse {

}


service TestGrpc {
    rpc Stream(stream test.basic.Message) returns(stream test.basic.Message) {}
}
複製程式碼

test.api.proto

syntax = "proto3";

package test.api;
option go_package = "xxxxxx/.go/test";

service TestApi {
    rpc SayHello(SayHelloRequest) returns(SayHelloResponse) {}
}

message SayHelloRequest {

}

message SayHelloResponse {

}
複製程式碼

這樣的話,各個專案只需要用指令碼選擇自己的模組,模組中需要的proto檔案,按需索取即可

編譯golang

proto編譯golang使用protoc外掛(專案地址)

如果按照上文進行proto檔案拆分,又需要把生成的檔案匯出到一個golang包裡,如果單獨編譯是不能跑起來的,因為有檔案引用的存在。所以需要一次性匯入該包下所有的proto檔案*.proto,筆者寫了個入門級的python指令碼輔助這一過程

build.py

import os

def genProto():
    print('作業系統:', os.name)

    fileList = os.listdir()
    folderList = []
    
    # 過濾掉隱藏資料夾 例如.git .vscode
    for i in range(0, len(fileList)):
        fileName = fileList[i]
        dotIndex = fileName.find('.')
        if (dotIndex < 0):
            folderList.append(fileName)
            
    print("folderList:", folderList)

    # 每個模組逐個編譯
    for folderName in folderList:
        os.system('bash buildProto.sh ' + "../../../ " + folderName)
genProto()
複製程式碼

buildProto.sh

echo "編譯$2.proto"
protoc -I . --go_out=plugins=grpc:$1 --micro_out=plugins=grpc:$1  $2/*.proto
複製程式碼

執行build.py,可在當前專案中把proto編譯到.go資料夾裡,每個模組一個golang包,達到了預期

牌類遊戲使用微服務重構筆記(六): protobuf爬坑

關於 ../../../

os.system('bash buildProto.sh ' + "../../../ " + folderName)

執行buildProto.sh指令碼傳入了第一個引數"../../../",這個與使用時golang的匯入路徑和option go_package = "xxxxxx/.go/test";有關係。在服務端專案中使用編譯後的golang檔案import "gitlab.com/xxx/xxx/.go/item",如果這個proto專案你是 go get拉取下來的,檔案結構會是$GOPATH/src/xxxx/xxxx/xxxx/.go,編譯生成的檔案也需要按照這個結構展開,所以需要告訴protoc --go_out=../../../, 這一點可以根據自己情況定製

編譯js/ts

npm install protobufjs 安裝pbjs 專案地址

gulp指令碼

var gulp = require('gulp');
var rename = require('gulp-rename');
var shell = require('gulp-shell');
var gulpSequence = require('gulp-sequence');

// 拷貝需要的proto
gulp.task('copy', ['clear'], () => {
    return gulp
        .src([
            `../path to your proto/*/*.basic.proto`,
        ])
        .pipe(rename({
            dirname: ''
        }))
        .pipe(gulp.dest(`protos/`));
});

gulp.task('clear', shell.task(['rm -rf protos']));

gulp.task('genProto', shell.task(['sh buildProto.sh']));
複製程式碼

buildProto.sh

# 生成js 為了節省空間 去掉了許多東西
pbjs -t static-module -w commonjs -o ./buildOut/proto.js ./protos/*.proto --no-create --no-verify --no-convert --no-delimited --no-beautify --no-comments

# 生成 .d.ts
pbts -o ./buildOut/proto.d.ts ./buildOut/proto.js
複製程式碼

不友好的oneof

在定義雙向流stream時 rpc Stream(stream test.basic.Message) returns(stream test.basic.Message) {}

如果Message內容比較簡單就能滿足需求了,但是假如像我們的遊戲需要對Message的內容進行分類:

1. req: 客戶端請求,要求服務端響應
2. notify: 客戶端通知,不要求服務端響應
3. rsp: 服務端響應(被動)
4. event:服務端推送事件(主動)
複製程式碼

那麼就需要一個解析Message的機制。同事提出了使用key當message 名字,寫一個for迴圈遍歷的方案,這樣甚至能同事發出去多條請求、多條事件,但最終覺得這樣會涉及到對key的排序問題最終沒有采用,而是使用了proto的oneof 語法

message Message {
    Req req = 1;
    Rsp rsp = 2;
    Notify notify = 3;
    Event event = 4;
}

message Req {
    oneof req {
        AuthReq authReq = 1;
    }
}

message AuthReq {

}

message Notify {
    oneof notify {
        HiNotify hiNotify = 1;
    }
}

message HiNotify {

}

message Rsp {
    oneof rsp {
        AuthRsp authRsp = 1;
    }
}

message AuthRsp {

}

message Event {
    oneof Event {
        FooEvent fooEvent = 1;
    }
}

message FooEvent {

}
複製程式碼

oneof欄位之間是共享記憶體的,同一時間只能設定其中一個,其他的會被清除,因此特別節約記憶體。業務程式碼在使用起來比如key當meesage名字也更加清晰明瞭(新增一個欄位 程式碼只需要在switch中新增一個case即可),只不過有兩個小坑:

  • 對golang不太友好: 如果要建立一個message,需要這樣寫 pb.Message{Req: &pb.Req{Req: &pb.Req_AuthReq{AuthReq: &pb.AuthReq{}}}}一大長串。。。檢視生成的原始碼可得知,之所以這樣是因為golang是通過介面實現 oneof的,因此只能一層一層包下去

  • json無法解析: 上面的請求轉成json為{"req":{"authReq":{}}},但這個字串無法直接轉成proto,需要先把{"authReq":{}轉成authReq,再包裝成pb.Message。如果前後端使用arrayBuffer則沒有這個問題。

對於第一個問題,寫好幾個輔助函式即可彌補;對於第二個問題,在我們的專案中只有很少數的http介面使用json並且碰到了oneof,因此一直在使用中

json

預設情況下,當需要將proto轉成json返回給http介面時(假如http返回的資料格式為json),那麼對於欄位的零值,將會被忽略。檢視生成的pb原始碼,會發現

type Message struct {
	Req                  *Req     `protobuf:"bytes,1,opt,name=req,proto3" json:"req,omitempty"`
	Rsp                  *Rsp     `protobuf:"bytes,2,opt,name=rsp,proto3" json:"rsp,omitempty"`
	Notify               *Notify  `protobuf:"bytes,3,opt,name=notify,proto3" json:"notify,omitempty"`
	Event                *Event   `protobuf:"bytes,4,opt,name=event,proto3" json:"event,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}
複製程式碼

這些欄位被加上了json:"omitempty"的tag,最可氣的是這個tag是protoc 寫死的... 解決辦法有

  • 修改protoc原始碼自定義這個行為
  • 自定義Marshaler
    m := jsonpb.Marshaler{EmitDefaults: true}
    複製程式碼
  • 使用指令碼移除這個標記,修改上面的build.py
    import os
    
    def changeFile(fileName, old_str, new_str):
        file_data = ""
        with open(fileName, "r", encoding="utf-8") as f:
            for line in f:
                if old_str in line:
                    line = line.replace(old_str, new_str)
                file_data += line
        with open(fileName, "w", encoding="utf-8") as f:
            f.write(file_data)
    
    def genProto():
        print('作業系統:', os.name)
    
        fileList = os.listdir()
        folderList = []
        
        # 過濾掉隱藏資料夾 例如.git .vscode
        for i in range(0, len(fileList)):
            fileName = fileList[i]
            dotIndex = fileName.find('.')
            if (dotIndex < 0):
                folderList.append(fileName)
                
        print("folderList:", folderList)
    
        # 每個模組逐個編譯
        for folderName in folderList:
            os.system('bash buildProto.sh ' + "../../../ " + folderName)
            
            # 換掉go裡的標記
            goFiles = os.listdir('.go/' + folderName)
            for i in range(0, len(goFiles)):
                fileName = goFiles[i]
                dotIndex = fileName.find('.pb.go')
                if (dotIndex >= 0):
                    # print("替換檔案:", fileName)
                    changeFile('.go/' + folderName + '/' +
                           fileName, ',omitempty', '')
    genProto()
    複製程式碼

本人學習golang、micro、k8s、grpc、protobuf等知識的時間較短,如果有理解錯誤的地方,歡迎批評指正,可以加我微信一起探討學習

牌類遊戲使用微服務重構筆記(六): protobuf爬坑

相關文章