转载

牌类游戏使用微服务重构笔记(六): protobuf爬坑

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爬坑
原文  https://juejin.im/post/5cb3eca56fb9a0685a3ef65f
正文到此结束
Loading...