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

前言

Protocol Buffer是Google的語言中立的,平臺中立的,可擴展機制的,用於序列化結構化數據 - 對比XML,但更小,更快,更簡單。您能夠定義數據的結構化,而後可使用特殊生成的源代碼輕鬆地在各類數據流中使用各類語言編寫和讀取結構化數據。python

主要有點有:git

  • 1.protoBuf在Google內部長期使用,產品穩定成熟,不少商業的項目都選擇使用github

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

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

  • 4.編碼和解碼的效率很是之高shell

  • 5.支持不一樣版本的協議向前兼容npm

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

protobuf 語法指南gulp

單獨一個項目?

若是要在多個項目中共用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 , 例如:

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包,達到了預期

關於 ../../../

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等知識的時間較短,若是有理解錯誤的地方,歡迎批評指正,能夠加我微信一塊兒探討學習

相關文章
相關標籤/搜索