小白可能不太好学会的nestjs+grpc+docker完整流程

3,542 阅读14分钟

前言

这一套服务我自己也搞了一个多礼拜,光从把服务部署到docker就弄了两天,期间遇到了各种奇怪的,网上找不到原因的报错,最后好不容易才让服务运行起来。加上grpc框架在node(或者说在nest上)使用的文章不多,大多是在go和python上使用的范例,解决问题的时候让我吃了很大的苦头,仅以此篇文章献给大家,愿我遇到的问题能够成为各位在成功路上的垫脚石。

起因

在一切开始之前,我想先聊一下为什么选择使用grpc框架。由于业务的发展,我们的服务被拆分成了两套,老的服务继续使用,在老服务的相同结构上重新架构一套新的服务来供新服务使用,其中很多接口需要调整,但登录方式不变。为了不重复写用户登录逻辑,user服务就需要独立出来。

至于为什么使用grpc而不是http来调用user,一方面grpc的通讯效率要更高;二方面我不希望user集成太多与业务相关的逻辑,最好能够只实现一些通用的用户创建和token底层逻辑,具体的业务判断还是放在具体服务上;三方面,grpc我之前只在其他同事封装的方法里接触过,想更多的了解一些这个通讯框架。

nestjs集成grpc

因为如果要全部代码都贴出来的话文章会很长,我希望能简短的讲完这个过程,所以下面我会只贴部分关键代码,如果有不清楚的地方可以在评论区留言,看到了会更新。

服务端

在使用grpc之前需要导入两个关键包,其他的包运行报错了再加也可以,不影响代码编写:

npm install @nestjs/microservices
npm install grpc

Main

grpc服务的服务端和普通的nestjs服务端启动其实并没有太大区别,主要区别就在于生成app时的操作不同

  const config = await setupNacosConfig();
  const app = await NestFactory.createMicroservice(AppModule, {
    transport: 4,
    options: {
      url: config.USER_SERVER_URL,// localhost:2900
      package: 'user',
      protoPath: join("src/myrpc", 'user.proto'),
    }
  });

这里的url填的是grpc需要监听的地址,很多教程会忽略这个参数,但是在这里我要重点提出来,因为这个我在这个参数上吃的苦头可能是整个整合过程中最多的。

如果grpc服务的调用方和该服务在同一个ip地址,这里的url可以填localhost:<你希望监听的端口>,但如果你是用docker启动这个服务的时候,请不要填写宿主机的ipv4地址,因为会出现如下报错:

/usr/src/node_modules/@grpc/grpc-js/src/server.ts:580
              deferredCallback(new Error(errorString), 0);
                               ^
Error: No address added out of total 1 resolved
    at bindResultPromise.then.errorString (/usr/src/node_modules/@grpc/grpc-js/src/server.ts:580:32)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

同样,如果你在docker填localhost:<端口>,也可能遇到和grpc启动成功但无法建立连接的情况,具体这个参数要怎么填,我会在后面docker部署时详细讲解,这里先默认你的grpc服务和客户端在同一个地址,这个url填写localhost:<端口>

然后是package,这个参数我想和protoPath一起讲,这涉及到一种新的语法,叫Protobuf,这是谷歌开源的一套跨平台资料序列化协议,是grpc快速的源头所在。

Proto

文件的语法不难,基本上照抄就完事了,这里我摘几个容易出错的点讲一下,以下面的user.proto文件为例:

syntax = "proto3";

import "google/protobuf/empty.proto";

package user;
service UserService {
 rpc   create ( UserLogin) returns ( UserLoginData ) {}
 rpc   dosubscribe ( WeChatEventObj) returns (google.protobuf.Empty) {}
}
message UserLogin {
  string phone = 1;
  string mail = 2;
  string pwd = 3;
  repeated WechatInfo wechatInfo = 4;
}
message WechatInfo {
  string openId = 1;
  bool subscribe = 2;
  string eventKey = 3;
}
message UserLoginData {
  string id = 1;
  string nickName = 2;
  string token = 3;
}
message WeChatEventObj {
  string ToUserName = 1;
  string FromUserName = 2;
  string CreateTime = 3;
  string MsgType = 4;
  string Event = 5;
  string EventKey = 6;
  string Ticket = 7;
}

首先是第一行,截至文章日期2022/11/10,当前最新的版本是proto3,照抄就行;

第二行引用了谷歌的一个内置包,这里引用这个包的目的在于,如果你的这个接口没有返回值(没必要返回信息或者不需要入参是常有的事),在入参和返回值一栏是不能空着的,必须填上google.protobuf.Empty,否则调用会报错。

第三行表示了这个文件所属的包名称,也就是上面启动时的需要填写的包名称。

创建参数的规则也很让人不习惯,首先,你的入参和返回值里不能出现两个或两个以上对象,也不能出现数组,所以你需要将入参和返回值浓缩成一个对象声明,如果你执意要在属性中加数组的话,可以在参数类型上加repeated,上面有参考,可以看一下。

这个文件建议不要手写,最好是写脚本自动生成,因为源文件每次改动这个文件都需要跟着改,时间长了会让人非常痛苦,后面还有个interface文件也是同理,最好是写个脚本自动生成对应文件。文末会附上我自己写的生成脚本,没什么技术含量,有需要的同学可以根据自己的实际情况做修改。

Controller

这里没有太多坑好踩的,只要你不作死乱填,基本不会有太大问题

  // 创建用户
  @GrpcMethod('UserService', 'create')
  async create(data: UserLogin): Promise<UserLoginData> {
    return await this.userService.create({ phone: data.phone, mail: data.mail, pwd: data.pwd });
  }

GrpcMethod的第一个参数是你在客户端getService方法中需要填写的名称,第二个参数是你在客户端调用方法时需要对应的方法名。虽然很多文章包括官方教程都在提醒你这两个参数可以省略,但我建议还是填上,避免未来看代码时造成困惑。

客户端

Interface

在进入客户端之前,先来聊聊一个文件,和之前user.proto对应的,还有一个user.interface.ts文件,这个文件不是必要的,但为了方便规范你的代码建议最好加上,原因我会在后面提到。

import { Observable } from "rxjs";

export interface UserRpc {
    create (data: UserLogin): Observable< Promise<UserLoginData> >;
    doSubscribe (data: WeChatEventObj): Observable< void >;
}
export class UserLogin {
    // 用户手机号
    phone?: string;
    // 用户邮箱
    mail?: string;
    // 用户密码
    pwd: string;
    // 微信信息
    wechatInfo?: WechatInfo[];
}
export class UserLoginData {
    id: string;
    nickName: string;
    token: string;
}
export class WechatInfo {
    // 微信id
    openId: string;
    // 是否订阅一目公众号
    subscribe: boolean;
    // 关注时扫码key值
    eventKey: string;
}
export class WeChatEventObj {
    // 开发者微信号
    ToUserName: string
    // 发送方帐号(一个OpenID)
    FromUserName: string
    // 消息创建时间 (整型)	
    CreateTime: string
    // 消息类型,event
    MsgType: string
    // 事件类型,subscribe(订阅)、unsubscribe(取消订阅)
    Event: string
    // 扫描带参数的二维码返回 事件 KEY 值,qrscene_为前缀,后面为二维码的参数值
    EventKey?: string
    // 二维码的ticket,可用来换取二维码图片
    Ticket?: string
}

Service

在客户端创建grpc连接的时候,需要填入一些参数,这些参数的含义在前面已经讲过了,就不再赘述:

    {
        transport: 4,
        options: {
            url: path, // 上面提到的user服务所在url
            package: "user",
            protoPath: join("src/rpc", "user.proto")
        }
    }

这里想提一下的是连接方式。

官方文档在创建连接时,建议使用@Client()装饰器,确实这种写法简单便捷,只需要将参数填进去就能够自动创建连接了:

    @Client(optional)
    client: ClientGrpcProxy;

但是会出现一个问题,当连接中的参数出现需要异步调用获取的情况下,这种方式就不再合适了,具体解决方法可以参考我的另一篇文章:如何在grpc中使用nacos异步配置连接地址

连接获取完成后,可以通过这种方式使用proto:

userRpcService: UserRpc

constructor() {
    this.userRpcService = client.getService<UserRpc>('UserService');
}

如果你对UserRpc这个名字眼熟的话,没错,就是上面提到的那个可有可无的interface文件。在这里你可以不声明userRpcService的类型直接使用any类型,这样你就可以不生成上面的interface文件,但是与此相对的,你就失去了所有的编辑器提示。所以为了编码方便,请善待你的interface吧。

docker部署

好了,终于来到整个过程中最痛苦的一步了,其实单论时间,使用docker部署花费的时间完全没有前几项的时间长,但是在部署过程中遇到的问题难以理解,又查不到文档,着实是让我掉了不少头发。

很多docker教程都是从Dockerfile文件开始的,虽然我确实是想写小白教程,但又感觉把精力过多浪费在这种资料众多的地方是一件让人很难以接收的事情,所以这部分内容请各位自行查找答案,应该不难,在这里我们把时间放在刀刃上,只讲容易出错的地方。

在这里推荐一下我自己封装的docker镜像:qperable/node-python-grpc:1.0

第一行替换成:FROM qper/node-python-grpc:1.0 as base

里面添加了一些其他包可能缺少的工具和依赖,避免了打包时自己安装工具的麻烦

在前文,我们提到服务的url是最不好填的,因为涉及到grpc内部服务的监听机制。我们知道,docker其实相当于是创建了一个与外界隔离的沙箱,所以,如果你在docker部署时,将监听url填成了localhost127.0.0.1的话,很有可能会遇上类似14 UNAVAILABLE: Connection dropped或者14 UNAVAILABLE: No connection established之类的错误,因为只开启了本机监听而没有监听来自其他ip地址的请求。这个时候其实应该填写的是当前服务器的ip。

但是我们又知道,docker启动的时候ip地址是随机分配的,我们并不会知道分配到的ip具体会是多少,这个时候要填写正确的ip地址根本不可能。在万般危急之下,感谢 哈喽沃德大大 的这篇文章救我与水火之中:Docker 网络模式详解及容器间网络通信

简单来说,docker之间的通信方式其实分很多种,在这里,我将选用其中的几种方式来作为我的解决方案。

host网络模式

简单来说,就是把宿主机的网卡作为docker的ip地址,这种方式打破了docker自身的网络隔离,看上去不是一种很优雅的方式,但确实我第一个尝试的。但使用host模式启动之后,我的服务端口直接无法访问了,也不想深究其中的原因,反正不是很想用。

container网络模式

简单来说就是可以在启动的时候指定一个已经启动的容器,和这个容器共享网络配置。这个模式是我第一次用就成功通信但也是研究时间最久的,因为我并不想服务各个独立的docker中还有一个配置需要依赖其他容器,这样就违背了最初使用docker的初衷,希望服务和服务尽量减小彼此之间的影响,于是我想到了类似注册中心的结构,所有服务都依赖一个公共的空服务,这个服务只提供网卡和接口暴露信息,这样就可以做到所有容器在同一个localhost下运行。

为了让这个中央服务足够小,我找到了scratch,号称最小的docker镜像(只有0B),老子在道德经里曾说过:有之以为利,无之以为用,一个东西什么都没有的时候就是它作用最大的时候。可惜直到最后我也没能让这个空空如也的镜像跑起来。

因为在实现过程中我一直在想一个问题,如果我新建了一个服务,或者原先的服务需要改变端口,我还需要将这个中央服务的端口号修改过来,那个时候我还会记得这个默默运行着的中央服务吗。当修改一处内容时需要同步另一处修改,这是很典型的坏味道,是让人很难接受的。于是有了最后一种网络模式。

自定义网络模式

这个网络模式很像创建了一个局域网,在这个局域网之中的docker服务可以互相之间通过别名调用。说到这里,你应该知道我想说什么了吧,对,通过别名调用的话就不需要知道当前docker服务的ip地址是多少了!

具体的操作步骤是这样的:

首先,创建一个网络模式:docker network create hello

然后,启动docker的时候连接这个网络模式:

docker run -d --name userserver -p 2900:2900 --net hello userserver

再来,你就可以在通过同一个--net下启动的服务的控制台中用--name配置的参数ping通这个服务了

/usr/src/app $ ping userserver
PING userserver (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=42 time=0.030 ms
64 bytes from 172.18.0.2: seq=1 ttl=42 time=0.119 ms
^C
--- userserver ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.030/0.074/0.119 ms

简单来说,就是你的path不再需要是一个准确的xxx.xxx.xxx.xxxipv4地址了,而是:userserver:2900,docker会自动将别名解析为对应的局域网地址(172.18.0.2)。

时间就是过得那么快(其实写这篇文章并不快,花了我近3个小时来总结归纳),马上就要遇到整个搭建过程中最让人头疼的问题了。我们上面提到,path是可以被docker识别并解析成对应ip地址的,但在实践过程中我不止一次地怀疑这个答案的真实性,因为我这么填了,但是服务连接不上。

而这一切的罪魁祸首,让我们回到最早的那一段代码

  const config = await setupNacosConfig();
  const app = await NestFactory.createMicroservice(AppModule, {
    transport: 4,
    options: {
      url: config.USER_SERVER_URL,// localhost:2900
      package: 'user',
      protoPath: join("src/myrpc", 'user.proto'),
    }
  });

我衷心希望你没有完全照抄这一段,如果照抄了也希望能够看到最后这里,因为如果真的是这样写的话你的服务应该是和我一样调不通的,原因就出在url的写法上。

nacos获取的url我曾console.log打印过,确实是userserver:2900,但是客户端无法通过这个配置调用到grpc服务,正确的写法应该是这样:

  const config = await setupNacosConfig();
  const app = await NestFactory.createMicroservice(AppModule, {
    transport: 4,
    options: {
      url: `${config.USER_SERVER_URL}`,// localhost:2900
      package: 'user',
      protoPath: join("src/myrpc", 'user.proto'),
    }
  });

url是string类型。

我不知道为什么会出现这样的问题,但它确实出现了,而且胜利就在眼前的情况下折磨了我整整一个下午,换了无数关键字到处搜索都找不到答案,最后是通过不断试错穷举才找到了正确的写法,也是我花三小时写这篇文章最后的动力,如果有朋友遇到和我一样的问题请不要放弃,加油,没什么困难是无法度过的。

补充

最近遇到一个问题,补充一下。 在接口返回的时候有一部分下划线命名的属性丢失,根据grpc的特性,没有在protobuf文件中声明的属性是肯定没法传输的,但在文件中声明的属性也丢失了,说明还有其他限制条件,于是我找到了如下这段:

image.png

大概意思就是,protobuf文件中可以使用下划线命名,但是在接口命名的时候返回值字段需要使用驼峰。

总结

最后的最后,如果有什么讲得不好的地方请多包涵,有什么写得不对的地方请不吝赐教,感谢看到最后,有缘再会。

附件:proto 文件和 interface 文件生成脚本

/**
 * 使用前请仔细阅读说明
 * 
 * 为了能够正常生成 interface 和 proto 文件,请严格按照规则编写 controller 文件,谢谢
 * 
 * 文件生成原理是通过读取 @GrpcMethod 装饰器,拿到下一行内容,通过解析该行内容生成对应文件
 * 所以,在写 controller 时注意将入参和返回值写在同一行
 * 
 * 类型生成是通过读取当前文件同名 schema 下类型为 class 的类型,所以请尽量把 controller 中涉及到的入参和返回值的类型写在 schema 文件夹下的 schema文件下
 * 且在声明 class 时请按照如下格式,注意空格位置
 * class XXX {
 * 为避免读取问题,如对象中包含有对象类型,请另外声明一个 class 并引用,不要在同一个 class 中多层嵌套
 * 
 * 由于 number 类型转换会存在 int 和 double 的区别,请尽量使用 string 类型传递数字
 * 
 * grpc 不能直接传数组(至少我还没找到直接返回数组的方式),只能把数组放进对象中以 repeated 的方式传过去
**/


const fs = require("fs");
const path = require("path");

const pathName = "" || __dirname;

// 读取路径下所有待生成文件夹名称
const dirName = path.join(pathName, "src/resource");
fs.readdir(dirName, function (err, files) {
    for (let file of files) {
        fs.stat(dirName, function (err, data) {
            if (data && !data.isDirectory()) return;

            const resourceName = path.join(dirName, file);
            const fileList = fs.readdirSync(resourceName);
            for (const controller of fileList) {
                // 找出其中所有的controller文件
                if (!controller.includes("controller")) continue;

                transTsToProto(resourceName, controller);
            }

        });
    }
});

function transTsToProto(filePath, fileOriName) {
    const fileName = fileOriName.substring(0, fileOriName.indexOf("."));
    let file = fs.readFileSync(path.join(filePath, fileOriName)).toString().split("\n");
    let functionProtoList = [];
    let functionRpcList = [];
    for (let i = 0; i < file.length; i++) {
        if (file[i].includes("@GrpcMethod")) {
            // 获取方法
            const functionInfo = file[i + 1].replace("async ", "");
            const functionName = functionInfo.substring(0, functionInfo.indexOf("("));
            const functionParams = functionInfo.substring(functionInfo.indexOf("(") + 1, functionInfo.lastIndexOf(")"));
            const functionResult = functionInfo.substring(functionInfo.lastIndexOf(")") + 1, functionInfo.lastIndexOf("{")).replace(":", "");
            const rpcResult = trimAll(functionResult).includes("void") ? "google.protobuf.Empty" : functionResult.replace("Promise<", "").replace(">", "");
            functionProtoList.push(` rpc ${firstToUpper(functionName)} (${functionParams.split(",").map(r => r.substring(r.indexOf(":") + 1)).join(",")}) returns (${transType(rpcResult)}) {}`);
            functionRpcList.push(`  ${functionName} (${functionParams}): Observable<${functionResult}>;`)
        }
    }

    let typeFile = fs.readFileSync(path.join(filePath, "schema", `${fileOriName.split(".")[0]}.schema.ts`)).toString().split("\n");
    let typeProtoList = [];
    let typeRpcList = [];
    for (let i = 0; i < typeFile.length; i++) {
        // 获取出入参对象
        if (typeFile[i].includes("class") && typeFile[i].includes("{")) {
            const trimFile = trimAll(typeFile[i]);
            const properties = trimFile.substring(trimFile.indexOf("class") + 5, trimFile.indexOf("{"));
            let proList = [];
            let rpcList = [];
            let j = i;
            let h = 1;
            while (j < typeFile.length - 1) {
                j++;
                if (!typeFile[j]) continue;
                if (typeFile[j].includes("//")) {
                    rpcList.push(typeFile[j]);
                    continue;
                }
                if (typeFile[j].includes("}")) break;
                const trimJ = trimAll(typeFile[j]);
                const jSplit = trimJ.replace(";", "").split(":");
                proList.push(`  ${jSplit[1].includes("[]") ? "repeated " : ""}${transType(jSplit[1].replace("[]", ""))} ${jSplit[0].replace("?", "")} = ${h};\n`);
                rpcList.push(`${typeFile[j]}`);
                h++;
            }
            typeProtoList.push(
                `message ${properties} {
${proList.join("")}}`);
            typeRpcList.push(
                `export class ${properties} {
${rpcList.join("")}
}`)
        }
    }

    let protoFile =
        `syntax = "proto3";

import "google/protobuf/empty.proto";

package ${fileName};
service ${firstToUpper(fileName)}Service {
${functionProtoList.join("\n")}
}
${typeProtoList.join("\n")}
`
    let interFile =
        `import { Observable } from "rxjs";

export interface ${firstToUpper(fileName)}Rpc {
${functionRpcList.join("\n")}
}

${typeRpcList.join("\n")}
`
    fs.writeFileSync(path.join(pathName, "src/myrpc", `${fileName}.proto`), protoFile);
    fs.writeFileSync(path.join(pathName, "src/myrpc", `${fileName}.interface.ts`), interFile);
    console.log(`${fileName}.controller complete`);
}

function firstToUpper(context) {
    return `${context[0].toLocaleUpperCase()}${context.slice(1)}`;
}

function trimAll(context) {
    return context.replace(/\s+/g, "");
}

function transType(type) {
    switch (trimAll(type)) {
        case "string": return "string";
        case "number": return "double";
        case "boolean": return "bool";
        default: return type;
    }
}

参考controller文件格式:

  // 创建用户
  @GrpcMethod('UserService', 'create')
  async create(data: UserLogin): Promise<UserLoginData> {
    return await this.userService.create({ phone: data.phone, mail: data.mail, pwd: data.pwd }, data.wechatInfo);
  }