云原生(Cloud Native)方向很难避开和 Golang,即 Go 语言的接触。我的目标是对云原生技术有一个比较系统的了解,为此需要对 Go 有一定的熟练度。以我的经验来看,要从零开始了解一个语言,最快最高效的方式就是用它做一个实践项目出来。尽管已经有无数前辈提醒我,实现系统在研究生阶段属于低级产出,但我认为作为一个练手的方式,用从未接触的语言实现一个完整的可行系统还是有很大的学习意义的。

该项目来源于网络上的相关教程。在完整实现后,我应该会在另一篇文章中通过将该项目“上云”来实现对云原生关键工具 Kubernetes 的进一步了解。

关于 Golang 的基本语法我基本不会大篇幅说明,但在原教程中前半部分都是相关内容,需要的朋友可以进行参考。而即使是在本文中的大部分内容,在 Golang 的文档中也能搜索到

将会根据实现进度实时更新,内容将分为多篇文章。

RPC

RPC 协议,即Remote Procedure Call Protocol (远程过程调用协议)。它主要用于远程进程通信,在 TCP/IP 模型上属于应用层协议( http 协议同层),其底层使用 TCP 实现。

通过 RPC 协议,进程能够像调用本地函数一样,去调用远程函数。通过 RPC 协议,能够向服务端传递函数名、函数参数。达到在客户端调用远端函数,取得返回值到本地的目标。这能够契合微服务之间高隔离性的特点,即不同的微服务都是在不同的进程甚至虚拟机(VM),运行在差异较大的生产环境中(例如不同语言编写的程序直接的通信)。

RPC使用步骤

  • 服务端:

    1. 注册 rpc 服务对象,给对象绑定方法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      rpc.RegisterName("服务名",回调对象)
      // 函数原型:
      func (server *Server) RegisterName(name string, rcvr interface{}) error
      /*
      参1:服务名。字符串类型。
      参2:对应 rpc 对象。 该对象绑定方法要满足如下条件:
      1)方法必须是导出的,(可以理解为 Java 中的 public ),go 中的首字母大写方法。
      2)方法必须有两个参数,都是导出类型(type定义的类型)、內建类型(go内置类型)。
      3)方法的第二个参数必须是 “指针” (作为传出参数)
      4)方法只有一个 error 接口类型的返回值。
      */

      // 举例,以下方法 HelloWorld 是符合要求的被绑定函数。
      // 注意到尽管业务上可能只关注这个函数,
      // 但是 RegisterName/Register 方法的参数需要传入该函数所绑定的对象(即 World )
      // 而不是函数 HelloWorld 本身
      // 在此例中可以把 HelloWorld 函数理解为回调函数
      type World stuct {
      }
      func (this *World) HelloWorld (name string, resp *string) error {
      }
      ...
      rpc.RegisterName("服务名"new(World))
    2. 创建监听器

      1
      listener, err := net.Listen()
    3. 建立连接

      1
      conn, err := listener.Accept()
    4. 将连接绑定 rpc 服务。

      1
      2
      3
      4
      rpc.ServeConn(conn)
      // 函数原型:
      func (server *Server) ServeConn(conn io.ReadWriteCloser)
      // conn: 成功建立好连接的 socket
  • 客户端:

    1. 用 rpc 连接服务器。

      1
      conn, err := rpc.Dial()
    2. 调用远程函数。

      1
      2
      3
      4
      5
      6
      7
      8
      conn.Call("服务名.方法名", 传入参数, 传出参数)
      // 函数原型:
      func (client *Client) Call(serviceMethod string, args interface{}, reply interface{}) error
      /*
      serviceMethod: “服务名.方法名”
      args:传入参数。方法需要的数据。
      reply:传出参数。定义 var 变量,&变量名(取地址)完成传参。
      */

RPC实践

编写一个简易客户端/服务端实现RPC的函数调用

  • 服务端
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
package main

import (
"fmt"
"net"
"net/rpc"
)

type RpcObject struct {
}

func (obj *RpcObject) Hello(content string, resp *string) error {
*resp = "Hello " + content
// return errors.New("unknown error")
return nil
}

func main() {
// 注册RPC
err := rpc.RegisterName("test", new(RpcObject))
if err != nil {
fmt.Println(err)
return
}

// 设置监听
listener, err := net.Listen("tcp", ":8838")
if err != nil {
fmt.Println(err)
return
}
defer func(listener net.Listener) {
err := listener.Close()
if err != nil {
fmt.Println(err)
}
}(listener)

// 接受监听
conn, err := listener.Accept()
if err != nil {
fmt.Println(err)
return
}
defer func(conn net.Conn) {
err := conn.Close()
if err != nil {
fmt.Println(err)
}
}(conn)

// 绑定服务
rpc.ServeConn(conn)
}

  • 客户端
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
package main

import (
"fmt"
"net/rpc"
)

func main() {
rpcConn, err := rpc.Dial("tcp", ":8838")
if err != nil {
fmt.Println(err)
return
}
defer func(rpcConn *rpc.Client) {
err := rpcConn.Close()
if err != nil {
fmt.Println(err)
}
}(rpcConn)

var ans string
err = rpcConn.Call("test.Hello", "Me", &ans)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(ans)
}

首先运行服务端,然后运行客户端,服务端应当输出如下内容:

1
Hello Me

注意,RPC 使用了 Go 特有的 gob 序列化,因此如果需要使用 Go 与不同语言微服务应用进行 RPC 通信,需要使用通用的序列化/反序列化方式,例如 Json

进行如下修改,可以使用 Json 对 RPC 通信进行序列化

  • 服务端
1
2
3
// 绑定服务
// rpc.ServeConn(conn)
jsonrpc.ServeConn(conn)
  • 客户端
1
2
3
// rpcConn, err := rpc.Dial("tcp", ":8838")	
// Go 序列化方式特殊,容易乱码
rpcConn, err := jsonrpc.Dial("tcp", ":8838")

RPC封装

背景上文提到了, RPC 注册对象绑定的函数(例中Hello())需要两个参数。然而实际使用中,Hello()即使只有一个或没有参数,也不会在编译时报错,却会在 RPC 对象注册位置运行时报错(猜测原因是RegisterName方法底层使用了反射机制)。我们需要服务端在注册对象时,在程序编译阶段就能检测出注册对象是否合法。

通过 Go 的多态,声明一个接口RPCRegisterInterface,绑定类(即例中RpcObject)必须实现该方法(即例子中的回调函数Hello必须符合该接口规范)。同时将 RPC 注册过程封装为函数RPCRegisterService

1
2
3
4
5
6
7
8
9
10
type RPCRegisterInterface interface {
Hello(string, *string) error
}

func RPCRegisterService(serviceName string, i RPCRegisterInterface) {
err := rpc.RegisterName(serviceName, i)
if err != nil {
return
}
}

注册 RPC 类的过程更改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
// err := rpc.RegisterName("test", new(RpcObject))
// if err != nil {
// fmt.Println(err)
// return
// }

// RpcObject 对象必须实现 RPCRegisterInterface 接口
// 否则此行函数调用在编译时报错
err := RPCRegisterService("test", new(RpcObject))
if err != nil {
fmt.Println(err)
return
}

客户端封装内容略

gRPC

安装

本文只说明 Go 环境下 gRPC 的安装,官方安装指引点此

  1. 安装 Protocol Buffer 3,地址点此。根据操作系统,而不要根据语言下载 Protocol Buffer 。解压后只有一个可执行文件,将其放在任意你喜欢的位置,并将该目录添加进环境变量

  2. 项目中分别安装 go 的 Protocol Buffer 和 gRPC 插件

    1
    2
    go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
    go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
  3. go.mod 中有以下语句则说明安装完成

    1
    2
    3
    4
    require (
    google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.2.0 // indirect
    google.golang.org/protobuf v1.28.1 // indirect
    )

Protocol Buffer

protobuf (Protocol Buffer) 是谷歌内部的混合语言数据标准。通过将结构化的数据进行序列化(串行化),用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。竞争对手有 jsonxml等。

protobuf 文件示例

用一个例子说明 protobuf 的格式。创建 pb.person.proto 文件,文件内容如下:

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
// FileName: tutorial.person.proto 
// 通常文件名建议命名格式为 包名.消息名.proto

// 表示正在使用proto2命令
syntax = "proto2";

//包声明,tutorial 也可以声明为二级类型。
//例如a.b,表示a类别下b子类别
package tutorial;

//编译器将生成一个名为person的类
//类的字段信息包括姓名name,编号id,邮箱email,
//以及电话号码phones
message Person {

required string name = 1; // (位置1)
required int32 id = 2;
optional string email = 3; // (位置2)

enum PhoneType {
//电话类型枚举值
//枚举需要从0开始
MOBILE = 0; //手机号
HOME = 1; //家庭联系电话
WORK = 2; //工作联系电话
}

//电话号码phone消息体
//组成包括号码number、电话类型 type
message PhoneNumber {
required string number = 1;
optional PhoneType type =
2 [default = HOME]; // (位置3)
}

repeated PhoneNumber phones = 4; // (位置4)
}


// 通讯录消息体,包括一个Person类的people
message AddressBook {
repeated Person people = 1;

}

字段解释

  • 消息体:protobuf 中定义一个消息类型是通过关键字 message 字段指定的,这个关键字类似于 C++/Java 中的 class 关键字。
  • 包声明.proto 文件以 package 声明开头,这有助于防止不同项目之间命名冲突。
  • 字段规则
    • required :消息体中必填字段,不设置会导致编解码异常。
    • optional :消息体中可选字段,可通过 default 关键字设置默认值。
    • repeated :消息体中可重复字段,重复的值的顺序会被保留,常用于保存数组。其中,proto3 默认使用 packed 方式存储,这样编码方式比较节省内存。
  • 标识号:在消息体的定义中,每个字段都必须要有一个唯一的标识号,标识号是 [0, 2^29 - 1] 范围内的一个整数。以 Person 为例,name=1,id=2, email=3, phones=4 中的1-4就是标识号。

gRPC 实战

详见 Golang 微服务实战 - 2. Consul