第二部分需要提到微服务架构中必不可少的功能:服务发现。我在先前做的 Spring Cloud 项目中使用过 Eureka 作为服务发现框架。但现在 Eureka 已经停更一年多,且使用 Go 编写的 Consul 更适合在 Go 环境微服务分布式系统上使用。

以下列出几篇文章,这几篇文章说明了有关服务发现的相关概念,并对当下热门服务发现框架进行了一些对比:《什么是服务发现》《服务发现框架Consul的使用》深入了解服务注册与发现

Consul

什么是 Consul ?官方网站的解释如下:

HashiCorp Consul 是一个网络服务解决方案,使团队能够管理服务之间、本地环境、多云环境以及运行时之间的安全网络连接。Consul 提供服务发现、服务网格、流量管理和网络基础设施设备的自动更新。您可以在单个 Consul 部署实例中单独或一起使用这些功能。

安装 Consul

官方网站下载二进制文件,随后解压至你所指定的文件目录。解压完成后,仅会得到一个二进制文件consul(Windows 下则是consul.exe)。将该二进制文件所在目录添加进系统环境变量,并在控制台输入指令consul以检查安装是否完成。

Consul 常用命令

全部 Consul 控制台命令详情请使用consul -h查看

本项目最常用的指令是consul agent,其主要功能为运行一个 consul 代理。consul agent最常使用的功能如下:

  • -h 查看consul agent的所有额外命令
  • -bind 0.0.0.0 指定 consul 所在机器的 IP 地址。默认值:0.0.0.0。注意,bind 后没有等号
  • -http-port=8500 consul 自带一个 web 访问的默认端口:8500
  • -client=127.0.0.1 表明哪些机器可以访问 consul ,默认为本机。0.0.0.0 则表示 所有其它机器均可访问。
  • -config-dir=path 该选项用于指定service的配置文件和检查定义所在的位置。通常会指定为某一个路径/consul.d(通常情况下,.d表示一系列配置文件存放的目录)
  • -config-file 指定一个要装载的配置文件。该选项可以配置多次,进而配置多个配置文件。
  • -data-dir=path 该选项用于指定 agent 储存状态的数据目录,这是所有 agent 都必须的,对于 server 尤其重要,因为他们必须持久化集群的状态。
  • -dev 开发者模式,该选项用于创建一个开发环境下的server节点,该参数配置下,不会有任何持久化操作,即不会有任何数据写入到磁盘。dev模式仅仅是在开发和测试环境中使用,不能用于生产环境。
  • -bootstrap-expect 该选项用于通知 consul server 类型节点,指定集群的 server 节点个数,该参数是为了延迟选举启动,直到全部的节点启动完毕以后再进行启动。
  • -node=hostname 该 node 选项用于指定节点在集群中的名称,该名称在集群中需要是唯一的,推荐直接使用机器的 IP。
  • -rejoin consul 启动的时候,设置其所加入到的 consul 集群
  • -server 以服务方式开启 consul ,允许其他的 consul 连接到开启的 consul上 (形成集群)。如果不加 -server, 表示以 “客户端” 的方式开启。不能被连接。每个数据中心(DC)的 server 数量推荐3到5个。所有的 server 节点加入到集群后要经过选举,采用 raft 一致性算法来确保数据操作的一致性。
  • -client 该参数用于指定 consul 界定为 client 节点类型。
  • -ui 可以使用 web 页面(进入该页面的 IP 地址与-bind命令有关,端口与-http-port命令有关)来查看服务发现的详情
  • -dc dc 是 datacenter 的简称,该选项用于指定节点加入的 dc 实例。

其它 consul 常用指令:

  • consul members 查看集群中的成员。
  • consul info 查看当前 consul 的 IP 等其它信息。
  • consul join 该命令的作用是将 agent 加入到 consul 的集群当中。当新启动一个 agent 节点后,往往需要指定节点需要加入到特定的 consul 集群中,此时使用 join 命令进行指定。
  • consul reload 重启 consul
  • consul leave 优雅的关闭 consul 不优雅指 ctrl + c

使用 Consul 注册服务

运行以下命令启动

1
consul agent -server -bootstrap-expect 1 -data-dir=d:/code/Consul/consul_data -node=n1 -bind 127.0.0.1 -ui -rejoin -config-dir=d:/code/Consul/consul.d/ -client 0.0.0.0

按以下步骤操作

  1. 进入配置文件路径 D:\code\Consul\consul_cfg

  2. 创建 json 文件 web.json

  3. 在该文件中,填写服务信息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "service": {
    "name": "Faceid",
    "tags": [
    "rails"
    ],
    "port": 9000
    }
    }

    Json 配置文件详细配置选项参见官方文档

  4. 重新启动 consul

    1
    consul agent -server -bootstrap-expect 1 -data-dir=d:/code/Consul/consul_data -node=n1 -bind 127.0.0.1 -ui -rejoin -config-dir=d:/code/Consul/consul.d/ -client 0.0.0.0
  5. 查询服务

    1. 浏览器查看:

      直接在浏览器输入http://127.0.0.1:8500/,进入 consul 仪表板查看

    2. 终端命令查看:

      输入如下指令:

      1
      curl -o d:/code/Consul/test.json 127.0.0.1:8500/v1/catalog/service/Faceid

      d:/code/Consul/test.json文件中读取数据即可

      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
      [
      {
      "ID": "57bee4ce-9f8d-b68c-3fa3-5d21f98e65aa",
      "Node": "n1",
      "Address": "127.0.0.1",
      "Datacenter": "dc1",
      "TaggedAddresses": {
      "lan": "127.0.0.1",
      "lan_ipv4": "127.0.0.1",
      "wan": "127.0.0.1",
      "wan_ipv4": "127.0.0.1"
      },
      "NodeMeta": {
      "consul-network-segment": ""
      },
      "ServiceKind": "",
      "ServiceID": "Faceid",
      "ServiceName": "Faceid",
      "ServiceTags": [
      "rails"
      ],
      "ServiceAddress": "",
      "ServiceWeights": {
      "Passing": 1,
      "Warning": 1
      },
      "ServiceMeta": {},
      "ServicePort": 9000,
      "ServiceSocketPath": "",
      "ServiceEnableTagOverride": false,
      "ServiceProxy": {
      "Mode": "",
      "MeshGateway": {},
      "Expose": {}
      },
      "ServiceConnect": {},
      "CreateIndex": 40,
      "ModifyIndex": 40
      }
      ]

Consul 健康检查

/consul.d路径下新建health.json,该配置会每隔5秒检查名为Faceid的 consul 服务在端口9000上的 http 请求是否有响应,如果超过1秒没有响应,则会判断该健康检查不通过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"service": {
"name": "Faceid",
"tags": [
"rails",
"time"
],
"address": "127.0.0.1",
"port": 9000,
"check": {
"id": "api",
"name": "HTTP API on port 9000",
"http": "http://localhost:9000",
"interval": "5s",
"timeout": "1s"
}
}
}

然后通过以下命令启动 consul :

1
consul agent -server -bootstrap-expect 1 -data-dir=d:/code/Consul/consul_data -node=n1 -bind 127.0.0.1 -ui -rejoin -config-dir=d:/code/Consul/consul.d/ -config-file d:/code/Consul/consul.d/health.json -client 0.0.0.0

http://127.0.0.1:8500/下的仪表板能看到服务健康状况:

Faceid 的服务信息

因为我们只是在测试 consul 的功能,并没有把微服务注册上去,因此这里的HTTP API on port 9000健康检查一定是不通过的,因为我们没有实现任何一个微服务实例能对该 consul 服务器每5秒发送一个 http 请求。同时,Faceid 服务的自检查则是能够通过 consul 健康检查的。

除了上述例子中 http 实现健康检查外,还可以使用 脚本tcpttl 等方式进行健康检查。

Consul 结合 gRPC

基本 gRPC 远程调用

Protocol Buffer

GoLand 项目中新建文件夹,创建 ConsulClient.goConsulServer.go 以及文件夹 pb。在文件夹 pb 中创建 protobuf 文件,取名为 pb.person.proto 。内容编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
syntax = "proto3";

package pb;

message Person {
string name = 1;
int32 age = 2;
}

// 添加 rpc服务
service hello {
rpc sayHello (Person) returns (Person);
}

使用语句 protoc --go-grpc_out=./ *.proto --go_out=./ *.proto 进行编译,生成两个文件:

  • pb.person.pb.go
  • pb.person_grpc.pb.go
服务端

ConsulServer.go 中编写如下代码

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
package main

import (
"context"
"fmt"
"google.golang.org/grpc"
"net"
"src/src/Consul/GRPC/pb"
)

type Children struct {
*pb.UnimplementedHelloServer // 向前兼容,即需要兼容未来的软件
}

func (c *Children) SayHello(_ context.Context, p *pb.Person) (*pb.Person, error) {
p.Name = "hello " + p.Name
return p, nil
}

func main() {
fmt.Println("hello")

// 注册服务
grpcServer := grpc.NewServer()
pb.RegisterHelloServer(grpcServer, new(Children))

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

// 绑定监听
err = grpcServer.Serve(listener)
if err != nil {
fmt.Println(err)
return
}
}

注意到在新版 gRPC 实现接口的类中,除了需要 protobuf 中定义的接口,还需要匿名嵌入(更通俗的方法讲就是继承) protobuf 生成的结构体(类) Unimplemented****Server ,其中 **** 是服务名。官方文档中对此的解释是保证程序的向前兼容( forward compatible ),即保证程序能在未来的版本迭代中保持健壮性。

通俗一点讲,当版本迭代或业务需求改变,protobuf 的内容或是提供的 gRPC 服务发生改变的时候,原本该服务的 gRPC 接口实现将全部报错;但如果所有接口实现都继承了 Unimplemented****Server 类,那么程序不会报错(因为父类 Unimplemented****Server 一定实现了该接口),而是会根据 Unimplemented****Server 类执行一定的错误处理和报告,保证了程序的健壮性。

客户端

ConsulClient.go 中编写如下代码

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
package main

import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"src/src/Consul/GRPC/pb"
)

func main() {
// 拨号,即找到服务地址,需要 gRPC 忽略安全策略才能保证正常运行
dial, err := grpc.Dial(":8800", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fmt.Println(err)
return
}

// gRPC 客户端创建
grpcClient := pb.NewHelloClient(dial)
person := pb.Person{
Name: "Kevin",
Age: 22,
}

// 远程调用方法
newPerson, err := grpcClient.SayHello(context.TODO(), &person)
if err != nil {
return
}
fmt.Println(newPerson)
}

先运行服务端,再运行客户端,看到客户端输出 name:"hello Kevin" age:22 则说明运行成功。

将 gRPC 服务注册到 Consul 上

  1. 修改服务端代码

    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
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    package main

    import (
    "context"
    "fmt"
    "github.com/hashicorp/consul/api"
    "google.golang.org/grpc"
    "net"
    "src/src/Consul/GRPC/pb"
    )

    type Children struct {
    *pb.UnimplementedHelloServer // 向前兼容,即需要兼容未来的软件
    }

    func (c *Children) SayHello(_ context.Context, p *pb.Person) (*pb.Person, error) {
    p.Name = "hello " + p.Name
    return p, nil
    }

    func main() {
    fmt.Println("hello")
    // 1. 创建 Consul 对象
    consulObj, err := api.NewClient(api.DefaultConfig())
    if err != nil {
    fmt.Println(err)
    return
    }

    // 2. 告诉consul, 即将注册的服务的配置信息
    reg := api.AgentServiceRegistration{
    // 服务节点 id,用于区分提供同一服务的不同服务器
    ID: "Faceid",
    Tags: []string{"grpc", "consul"},
    // 服务名
    Name: "grpc And Consul",
    Address: "127.0.0.1",
    Port: 8800,
    Check: &api.AgentServiceCheck{
    // 该服务节点 id下,服务自健康检查的 ID
    CheckID: "consul grpc test",
    TCP: "127.0.0.1:8800",
    Timeout: "1s",
    Interval: "5s",
    },
    }

    // 3. 把 gRPC 服务注册到 Consul 上
    err = consulObj.Agent().ServiceRegister(&reg)
    if err != nil {
    return
    }

    // gRPC 操作
    grpcServer := grpc.NewServer()
    pb.RegisterHelloServer(grpcServer, new(Children))

    listener, err := net.Listen("tcp", ":8800")
    if err != nil {
    fmt.Println(err)
    return
    }
    defer func(listener net.Listener) {
    err := listener.Close()
    if err != nil {
    fmt.Println(err)
    }
    }(listener)

    fmt.Println("服务启动...")

    err = grpcServer.Serve(listener)
    if err != nil {
    fmt.Println(err)
    return
    }
    }

  2. 修改客户端代码

    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
    56
    57
    58
    59
    60
    61
    package main

    import (
    "context"
    "fmt"
    "github.com/hashicorp/consul/api"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "src/src/Consul/GRPC/pb"
    "strconv"
    )

    func main() {
    // 1. 创建 Consul 对象
    consulObj, err := api.NewClient(api.DefaultConfig())
    if err != nil {
    fmt.Println(err)
    return
    }

    // 2. 从 consul 上获取健康的服务
    /*
    func (h *Health) Service(service, tag string, passingOnly bool, q *QueryOptions) ([]*ServiceEntry, *QueryMeta, error)
    - 参数:
    service: 服务名。 -- 注册服务时,指定该string
    tag:标签名/别名。 如果有多个, 任选一个
    passingOnly:是否通过健康检查。 true
    q:额外查询参数。 通常传 nil
    - 返回值:
    ServiceEntry: 存储服务的切片。
    QueryMeta:额外查询返回值。 nil
    error: 错误信息
    */
    services, _, err := consulObj.Health().Service("gRPC And Consul", "grpc", true, nil)
    if err != nil {
    fmt.Println(err)
    return
    }

    // 可在此引入负载均衡算法
    ipAddr := services[0].Service.Address + ":" + strconv.Itoa(services[0].Service.Port)

    dial, err := grpc.Dial(ipAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
    fmt.Println(err)
    return
    }

    grpcClient := pb.NewHelloClient(dial)
    person := pb.Person{
    Name: "Kevin",
    Age: 22,
    }

    newPerson, err := grpcClient.SayHello(context.TODO(), &person)
    if err != nil {
    return
    }
    fmt.Println(newPerson)
    }

  3. 通过指令 consul agent -dev 直接以 dev 模式启动 Consul ,然后先后启动服务端与客户端。在客户端看到输出:name:"hello Kevin" age:22,并且在 Consul 控制台中看到输出:

    1
    2022-10-15T15:19:18.385+0800 [DEBUG] agent.http: Request finished: method=GET url=/v1/health/service/gRPC%20And%20Consul?passing=1&tag=grpc from=127.0.0.1:65497 latency=2.0771ms

以上,说明我们成功将 gRPC 服务注册到 Consul 上。在 127.0.0.1:8800 的仪表板上也能看到名字为 gRPC And Consul 的服务。

服务注销

1
2
3
4
5
6
7
8
9
10
11
package main

import "github.com/hashicorp/consul/api"

func main() {
// 1. 创建 consul 对象
consulObj, _ := api.NewClient(api.DefaultConfig())

// 3. 注销服务
consulObj.Agent().ServiceDeregister("Faceid")
}