使用gin作网关, etcd作服务发现实现go-grpc微服务的保姆级教程

在网上有着数不清的帖子教学如何单独搭建grpc与etcd、如何使用go-grpc-gateway等等教程,如果你在读过这一大堆的文章教程之后,
仍然是没有搞懂网关如何接管RESTAPI的请求并转发、服务间如何通信、网关如何鉴权,以及这每一块积木我都知道是做什么的,到底如何
拼起来这样的疑惑,可以通过读完这篇文章,快速构建起整个微服务框架。

Before Started

在开始之前,你应该先有golang的基础,如果还不明白golang怎么用,就先不要看这里了。

如果你想直接看demo,或者后面有减少文章字数需要用到源码的地方,源码,点击获取

etcd

首先,如果你还未搭建起ETCD服务,可以借鉴这份docker-compose,或者百度谷歌上有各种教程。

etcd docker-compose file

protoc

1
2
3
git clone https://github.com/golang/protobuf.git && cd protobuf
go install ./proto
go install ./protoc-gen-go

Let’s Code

首先,你需要两个项目,分别是 gateway 和 ping, 分别是网关和其中的微服务,对于多个微服务之间的通信会在最后提到。

ping 项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
- discovery 服务注册
- instance.go
- register.go
- resolver.go
- proto proto文件
- pb
- ping.pb.go
- ping.proto
- service
- ping_service.go
- main.go main文件
- go.mod
- go.sum

discovery

discovery文件是用来向ETCD注册服务、keep-alive、获取服务信息等功能的一套示例,对还在学习如何搭建起一套微服务框架的你,最好在之后再去搞懂这个文件的作用,目前先把源码展示出来,将它按照结构复制进去即可,我会在之后的帖子详细讲述这些文件的内容都做了什么

discovery

在后面说到网关项目的时候,会附上一段如果你不想跑封装好的discovery而是使用原生包的源码,可以在gateway参考一下

proto

按照结构创建文件,并在user.proto内写入如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
syntax = "proto3";

package ping;

option go_package = "/proto/pb;pb";


service Ping {
rpc Pong (Req) returns (Resp) {}
}

message Req {
string name = 1;
}

message Resp {
string msg = 1;
}

在项目文件夹内命令行输入命令生成pb文件

1
protoc -I . --go_out=plugins=grpc:. proto/pb/*.proto

service

找到pingServer的interface, 并在自己的service文件中补齐interface内的方法

ping.pb.go

1
2
3
type PingServer interface {
Pong(context.Context, *Req) (*Resp, error)
}

写入service/ping_service.go, 并补齐缺失的import、user的model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package service

import (
"context"
"ping/proto/pb"
)

type PingService struct {
}

func NewPingService() *PingService {
return &PingService{}
}

func (s *PingService) Pong(ctx context.Context, in *pb.Req) (*pb.Resp, error) {

msg := fmt.Sprintf("hello %s", in.Name)

return &pb.Resp{
Msg: msg,
}, nil
}

main.go

最后就是main文件

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

import (
"fmt"
"net"
"ping/discovery"
"ping/proto/pb"
"ping/service"

"github.com/sirupsen/logrus"
"google.golang.org/grpc"
)

const (
// 微服务名称
app = "ping"
// 微服务地址
grpcAddress = "127.0.0.1:10004"
)

func main() {
// etcd 地址
etcdAddress := []string{"127.0.0.1:2379"}

// 服务注册
etcdRegister := discovery.NewRegister(etcdAddress, logrus.New())
defer etcdRegister.Stop()

node := discovery.Server{
Name: app,
Addr: grpcAddress,
}

server := grpc.NewServer()
defer server.Stop()

// 绑定service
pb.RegisterPingServer(server, service.NewPingService())

lis, err := net.Listen("tcp", grpcAddress)
if err != nil {
panic(err)
}
defer lis.Close()

if _, err := etcdRegister.Register(node, 10); err != nil {
panic(fmt.Sprintf("start server failed, err: %v", err))
}

logrus.Info("server started listen on ", grpcAddress)
if err := server.Serve(lis); err != nil {
panic(err)
}
}

至此,如果你跟着完成了ping服务,可以跑起项目看一下,如果你有etcd的看板或者你使用了我的compose文件,可以看到/ping/127.0.0.1:10004下的服务

1
2
3
4
5
6
7
// 权重与版本没有声明,weight用于负载均衡
{
"name":"ping",
"addr":"127.0.0.1:10004",
"version":"",
"weight":0
}

gateway 项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- discovery       服务注册 与ping服务内容一致
- middleware
- jwt.go jwt鉴权中间件
- proto proto文件 该文件是ping项目内的proto文件
- pb
- ping.pb.go
- ping.proto
- routers
- ping.go
- route.go
- utils
- res 一些gin相关返回值的封装 复制或者自己写就好
- jwt.go jwt鉴权
- main.go main文件
- go.mod
- go.sum

看到gateway的项目结构可能你会有两个小问题:

  • 为什么选择在网关处使用gin而不是grpc-gateway
    • 答:是因为gin更符合我以前做单体页面时候的习惯,你可以像以前一样使用中间件、路由,还有比这个更好的吗?
  • 为什么gateway里还有微服务的protobuf文件,我以后的项目也要这样吗?
    • 答:当然不是,我在这里只是偷了个懒,实际生产中你应该将所有的微服务模块上传git服务器,通过包的形式引入各个项目,在服务之间使用服务发现通讯也是如此。假如你有一个非常基础的模块user,模块内有user的model、方法,你更应该将user这个模块通过golang包的形式引入其他项目而不是同时维护多个model(不用想也知道维护多个model的后果是什么吧?)

discovery文件

在ping服务有讲,不再重复

proto文件

引用了ping服务的proto文件

routes

ping.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
47
48
49
50
51
52
53
54
55
56
package routes

import (
"gateway/proto/pb"
"gateway/utils/res"

"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/balancer/roundrobin"
)

func addPingRoute(rg *gin.RouterGroup) {
opts := []grpc.DialOption{
grpc.WithInsecure(),
// 在这里出现了负载均衡,在完成了整个项目之后你可以创建两个ping服务同时注册
// 同时通过网关请求多次试一试
// 你会发现 一般情况下,两个ping服务会平分请求,也就达到了负载均衡
// 具体均衡规则还可以通过配置weight来实现
grpc.WithBalancerName(roundrobin.Name),
}

// 通过etcd做服务发现 在这里你并不是通过请求ping服务的10004来连接而是通过etcd去寻找ping服务
pingConn, err := grpc.Dial("etcd:///ping", opts...)
if err != nil {
logrus.Errorf("try connect ping service failed")
}
// 不要调用pingConn.Close() 因为addRoute方法只是导入了路由与对应的方法
// 在读取完毕之后会关闭链接 读取完毕之后的请求会无法链接 因为链接已经关闭
// 如果你一定要严谨的关闭 就把它移出去到公共的地方注册链接 并创建方法在优雅关闭内结束

pingClient := pb.NewPingClient(pingConn)

// 在这里 分组 路由的方法等 如果用过单体gin的同学是不是很眼熟了
ping := rg.Group("/ping")
{
ping.GET("", func(ctx *gin.Context) {
name := ctx.Query("name")

req := &pb.Req{
Name: name,
}

resp, err := pingClient.Pong(ctx, req)
if err != nil {
logrus.Errorln("/v1/ping err, err: ", err)
res.InternalError(ctx)
return
}

// 这里是自己封装的方法
res.Ok(ctx, res.OK, resp.Msg)
})
}
}

route.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
package routes

import (
"gateway/middleware"
"gateway/utils/res"
"net/http"

"github.com/gin-gonic/gin"
)

// LoadGin 初始化gin 注册路由
func LoadGin() *gin.Engine {

g := gin.Default()

g.Use(middleware.Cors())

getRoute(g)

g.NoRoute(func(ctx *gin.Context) {
res.Error(ctx, http.StatusNotFound, res.UrlNotFound)
})

return g
}

func getRoute(g *gin.Engine) {

v1 := g.Group("/v1")
addPingRoute(v1)

// ...
}

main.go

终于,gateway项目也要完成了(jwt鉴权在跑通gateway与ping之后)

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

import (
"gateway/discovery"
"gateway/routes"
"gateway/utils"
"net/http"
"time"

"github.com/sirupsen/logrus"
"google.golang.org/grpc/resolver"
)

func main() {

// etcd注册
r := discovery.NewResolver([]string{"localhost:2379"}, logrus.New())
defer r.Close()
resolver.Register(r)

// 初始化gin 加载路由
g := routes.LoadGin()

server := &http.Server{
Addr: ":8080",
Handler: g,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}

go func() {
// 优雅关闭http server 源码内有该方法,你也可以直接抹掉这一行
// 为什么要优雅关闭server?
// https://studygolang.com/articles/12600
utils.GracefullyShutdown(server)
}()

logrus.Info("gateway listen on :8080")

if err := server.ListenAndServe(); err != nil {
logrus.Fatal("gateway启动失败, err: ", err)
}
}

现在 跑起gateway与ping服务,用postman等请求工具请求一下localhost:8080/v1/ping?name=jay

或者直接命令行请求

1
curl -v "http:/127.0.0.1:8080/v1/ping?name=jay"

(我这里用的wsl2, 还配置了代理,需要请求windows的地址,所以我习惯用工具请求)

如果你得到的是

1
{"code":1001,"data":"hello jay","msg":"成功"}% 

恭喜你已经打通整个微服务,如果你已经可以熟练的使用gin或者知道如何使用jwt中间件,到这里你就完成了完整的流程。好像还有一点没提到,就是两个微服务之间如何相互请求,答案就在gateway的routes/ping.go文件里,通过服务发现去请求咯。

使用jwt中间件鉴权

如果你还不知道如何使用gin的中间件或者就是想看一下,接下来是如何使用jwt。做法是在网关处统一声明需要和不需要鉴权的路由,在通过http请求的时候,会通过gin来验证权限。而在微服务内部,使用服务发现的形式的请求就不需要鉴权服务了。

middleware/jwt.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package middleware

import (
"gateway/utils"
"gateway/utils/res"
"time"

"github.com/gin-gonic/gin"
)

// JWT token的生成是使用用户名、密码等一系列信息生成 在解密的时候可以拿到这些信息
func JWT() gin.HandlerFunc {
return func(ctx *gin.Context) {
var claims *utils.Claims
var err error

code := res.OK

// 对于我个人喜欢将鉴权的token放到header中的Authorization中
// 如果你有自己的想法,可以在这里获取你将token放的位置
token := ctx.GetHeader("Authorization")
if token == "" {
res.Unauthorized(ctx, res.TokenInvalid)

ctx.Abort()
return
}

// 验证
claims, err = utils.ParseToken(token)
if err != nil {

res.Unauthorized(ctx, res.TokenInvalid)

ctx.Abort()
return
}

// 判断是否过期
if time.Now().Unix() > claims.ExpiresAt {
code = res.TokenExpired
} else if err != nil {
code = res.TokenInvalid
}

if code == res.TokenExpired {
res.Ok__(ctx, res.TokenExpired)
} else if code != res.OK {

res.Unauthorized(ctx, code)

ctx.Abort()
return
}

// 我在这里的操作是将token对应的user放在了上下文内,
// 对于需要获取用户的方法,只通过这里就可以拿到userid
// 而不再需要通过传输参数的方式传入用户信息
ctx.Set("userid", claims.Userid)
ctx.Set("username", claims.Username)
ctx.Next()
}
}

gateway/routes/ping.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
47
48
49
50
51
52
package routes

import (
"gateway/middleware"
"gateway/proto/pb"
"gateway/utils/res"

"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/balancer/roundrobin"
)

func addPingRoute(rg *gin.RouterGroup) {
opts := []grpc.DialOption{
grpc.WithInsecure(),
grpc.WithBalancerName(roundrobin.Name),
}

// 通过etcd做服务发现
pingConn, err := grpc.Dial("etcd:///ping", opts...)
if err != nil {
logrus.Errorf("try connect ping service failed")
}
// 不要调用pingConn.Close() 因为addRoute方法只是导入了路由与对应的方法
// 在读取完毕之后会关闭链接 在读取完毕之后的请求会无法链接 因为链接已经关闭
// 如果你一定要严谨的关闭 就把它移出去到公共的地方注册链接 并创建方法在优雅关闭内结束链接

pingClient := pb.NewPingClient(pingConn)

ping := rg.Group("/ping")
// 在这里使用中间件
ping.Use(middleware.JWT())
{
ping.GET("", func(ctx *gin.Context) {
name := ctx.Query("name")

req := &pb.Req{
Name: name,
}

resp, err := pingClient.Pong(ctx, req)
if err != nil {
logrus.Errorln("/v1/ping err, err: ", err)
res.InternalError(ctx)
return
}

res.Ok(ctx, res.OK, resp.Msg)
})
}
}

然后再去请求你就可以实现jwt的鉴权服务了,在需要的路由添加鉴权中间件即可。

如果有同学还不知道jwt的token是怎么生成的,就在 “utils/jwt.go” 中,注释写的很全。流程是用户登录之后,你将用户信息生成token返回给用户,之后用户每次请求都将token存在请求头中的Authorization中,就可以实现鉴权了,别忘了在token过期之后通知用户重新获取token~。

end

最后的最后,如果有疑问、觉得我哪里没有讲清楚、自己跑不起来或者报错等等等等,可以留言或者email我。我写how to的唯一原因,就是想干掉所有写的不清不楚、看了等于没看的how to。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2019-2021 Jayj
  • 访问人数: | 浏览次数:

buy me a cup of coffee?

支付宝
微信