使用Go语言创建3个微服务和1个API网关 (2022版)
我们会一起开发3个微服务和1个处理HTTP请求的API网关。HTTP请求会通过gRPC转发给这些微服务。此外还会同时处理JWT验证。
本文由两部分组成,第二部分请点击这里。
我们要创建什么应用呢?
我们会一起构建一套小型电商系统,项目使用Go语言进行开发。
应用架构
本系列文章分成两个部分:
- API网关: 处理HTTP请求
- Auth服务: 提供注册、登录及通过JWT生成Token等功能
- Product服务: 提供添加商品、减库存和查找商品等服务
- Order服务: 该微服务中我们只提供了创建订单功能
每个微服务都是独立的项目。
因本系列文章阅读较为耗时,所以尽可能地让这些微服务保持简洁。我们不会涉及Docker或超时问题。
学习完本部分后,读者可以使用API网关和其中一个微服务新建用户。但要求对Go和gRPC有一定的基础。
下面开干!
创建数据库
1 2 3 4 5 6 7 |
$ psql postgres $ CREATE DATABASE auth_svc; $ CREATE DATABASE order_svc; $ CREATE DATABASE product_svc; $ \l $ \q |
执行\l
的结果如下图。可以看到我们创建了3个数据库。
对PostgreSQL不熟悉的用户也不必担心,使用MySQL完全没有问题,考虑到国内大部分开发者更习惯使用MySQL,后续代码将使用MySQL,其实使用GORM对开发而言两者的差别并不大。
1 2 3 4 5 6 7 |
$ mysql -uroot -p $ CREATE DATABASE auth_svc; $ CREATE DATABASE order_svc; $ CREATE DATABASE product_svc; $ SHOW DATABASES; $ exit |
创建项目
首先我们需要创建项目。推荐创建一个存储Go项目的工作目录。入口目录读者可自行选定。
注:本文中有大量的创建目录和切换目录等命令,Windows的用户可使用Git Bash或直接在IDE中创建对应的目录
1 2 3 4 |
$ mkdir go-grpc-project $ cd go-grpc-project $ mkdir go-grpc-api-gateway go-grpc-auth-svc go-grpc-order-svc go-grpc-product-svc |
先开发API网关。
1 2 |
$ cd go-grpc-api-gateway |
API网关
从API网关讲起可能会略显无趣,但在完成后就可以将微服务结合API网关直接进行测试了。
初始化项目
1 2 |
$ go mod init go-grpc-api-gateway |
安装模块
1 2 3 4 |
$ go get github.com/gin-gonic/gin $ go get github.com/spf13/viper $ go get google.golang.org/grpc |
项目结构
我们需要设置整个项目。我个人偏好在一开始就创建好所有目录和文件。有挺多目录文件,请坐稳了。
文件夹
1 2 |
$ mkdir -p cmd pkg/config/envs pkg/auth/pb pkg/auth/routes pkg/order/pb pkg/order/routes pkg/product/pb pkg/product/routes |
文件
1 2 3 4 5 |
$ touch Makefile cmd/main.go pkg/config/envs/dev.env pkg/config/config.go $ touch pkg/auth/pb/auth.proto pkg/auth/routes/login.go pkg/auth/routes/register.go pkg/auth/client.go pkg/auth/middleware.go pkg/auth/routes.go $ touch pkg/product/pb/product.proto pkg/product/routes/create_product.go pkg/product/routes/find_one.go pkg/product/client.go pkg/product/routes.go $ touch pkg/order/pb/order.proto pkg/order/routes/create_order.go pkg/order/client.go pkg/order/routes.go |
项目结构如下图所示:
以上使用的是VS Code,如果希望显示同样的图标,请安装Material Icon Theme插件。下面就开始写代码吧。
Protobuf文件
首先我们需要分别为3个微服务添加Protobuf文件。
认证微服务的Proto
第一个protobuf文件用于构建认证微服务。可以看到我们会定义三个端点
- Register
- Login
- Validate (JSON Web Token)
在pkg/auth/pb/auth.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 45 46 47 |
syntax = "proto3"; package auth; option go_package = "./pkg/auth/pb"; service AuthService { rpc Register(RegisterRequest) returns (RegisterResponse) {} rpc Login(LoginRequest) returns (LoginResponse) {} rpc Validate(ValidateRequest) returns (ValidateResponse) {} } // Register message RegisterRequest { string email = 1; string password = 2; } message RegisterResponse { int64 status = 1; string error = 2; } // Login message LoginRequest { string email = 1; string password = 2; } message LoginResponse { int64 status = 1; string error = 2; string token = 3; } // Validate message ValidateRequest { string token = 1; } message ValidateResponse { int64 status = 1; string error = 2; int64 userId = 3; } |
订单微服务的Proto
订单微服务仅处理一项任务,创建订单。因此我们需要商品ID (稍后进行获取)、数量和用户ID。
在pkg/order/pb/order.proto
中添加如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
syntax = "proto3"; package order; option go_package = "./pkg/order/pb"; service OrderService { rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse) {} } message CreateOrderRequest { int64 productId = 1; int64 quantity = 2; int64 userId = 3; } message CreateOrderResponse { int64 status = 1; string error = 2; int64 id = 3; } |
商品微服务的Proto
与订单微服务通讯的准备已就绪,下面来对商品微服务做同样的操作。
Proto文件
这次会包含三个端点:
- 创建商品
- 查找单个商品
- 扣除商品库存
在pkg/product/pb/product.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 45 46 47 48 49 50 51 52 53 54 55 |
syntax = "proto3"; package product; option go_package = "./pkg/product/pb"; service ProductService { rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse) {} rpc FindOne(FindOneRequest) returns (FindOneResponse) {} rpc DecreaseStock(DecreaseStockRequest) returns (DecreaseStockResponse) {} } // CreateProduct message CreateProductRequest { string name = 1; int64 stock = 2; int64 price = 3; } message CreateProductResponse { int64 status = 1; string error = 2; int64 id = 3; } // FindOne message FindOneData { int64 id = 1; string name = 2; string sku = 3; int64 stock = 4; int64 price = 5; } message FindOneRequest { int64 id = 1; } message FindOneResponse { int64 status = 1; string error = 2; FindOneData data = 3; } // DecreaseStock message DecreaseStockRequest { int64 id = 1; int64 orderId = 2; } message DecreaseStockResponse { int64 status = 1; string error = 2; } |
Makefile
下来我们来编写Makefile。这里添加两条命令来执行其它命令。听起来像套娃,可以认为是一种快捷方式。
这样我们不用再敲下冗长的protoc pkg/**…
,只要键入make proto
即可。
下面就对Makefile
添加代码:
1 2 3 4 5 6 |
proto: protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=. server: go run cmd/main.go |
现在我们就可以通过刚刚创建的proto文件来生成protobuf文件了。
1 2 |
$ make proto |
控制台中的输出应该也非常简单:
1 2 3 |
$ make proto protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=. |
生成的protobuf文件会和.proto
文件放在一起,如下图:
环境变量
我们需要定义一些环境变量。
在pkg/config/envs/dev.env
中加入如下代码:
1 2 3 4 5 |
PORT=:3000 AUTH_SVC_URL=localhost:50051 PRODUCT_SVC_URL=localhost:50052 ORDER_SVC_URL=localhost:50053 |
配置
在这一文件中,我们将环境文件中的数据拉给到API网关。
在pkg/config/config.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 |
package config import "github.com/spf13/viper" type Config struct { Port string `mapstructure:"PORT"` AuthSvcUrl string `mapstructure:"AUTH_SVC_URL"` ProductSvcUrl string `mapstructure:"PRODUCT_SVC_URL"` OrderSvcUrl string `mapstructure:"ORDER_SVC_URL"` } func LoadConfig() (c Config, err error) { viper.AddConfigPath("./pkg/config/envs") viper.SetConfigName("dev") viper.SetConfigType("env") viper.AutomaticEnv() err = viper.ReadInConfig() if err != nil { return } err = viper.Unmarshal(&c) return } |
API网关的主要配置已完成。下面就需要编写三个微服务的客户端代码,服务端代码稍后编写。
Auth微服务的端点
现在我们来编写对Auth微服务的实现,该服务现在还不存在。但因为我们已定义了protobuf文件,所以知道各个微服务的请求和响应。
注册路由
如果用户希望注册账号,需要向API网关发送一条请求,我们接收到请求再转发给认证微服务。该微服务会返回响应。当然我们还没有编写这个微服务,但已经知道它接收的数据及返回的响应内容。
因此这里我们创建一个结构体RegisterRequestBody
,用于绑定HTTP请求体,然后绑定所要做的gRPC请求的请求体。
在pkg/auth/routes/register.go
中添加如下代码:
注意! 在大部分Go文件中,需要将项目名称替换成你自己的。此处模块名位于第5行,请自行替换。
哈哈,其实大可不必担心,因为我在这里使用了本地路径~
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 |
package routes import ( "context" "go-grpc-api-gateway/pkg/auth/pb" "net/http" "github.com/gin-gonic/gin" ) type RegisterRequestBody struct { Email string `json:"email"` Password string `json:"password"` } func Register(ctx *gin.Context, c pb.AuthServiceClient) { body := RegisterRequestBody{} if err := ctx.BindJSON(&body); err != nil { ctx.AbortWithError(http.StatusBadRequest, err) return } res, err := c.Register(context.Background(), &pb.RegisterRequest{ Email: body.Email, Password: body.Password, }) if err != nil { ctx.AbortWithError(http.StatusBadGateway, err) return } ctx.JSON(int(res.Status), &res) } |
登录路由
登录路由与注册路由非常类似。首先绑定HTTP请求体,然后发送请求体给认证微服务。
微服务的响应为JWT令牌, 这个token在后续编写的路由中会使用到。
在pkg/auth/routes/login.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 |
package routes import ( "context" "go-grpc-api-gateway/pkg/auth/pb" "net/http" "github.com/gin-gonic/gin" ) type LoginRequestBody struct { Email string `json:"email"` Password string `json:"password"` } func Login(ctx *gin.Context, c pb.AuthServiceClient) { b := LoginRequestBody{} if err := ctx.BindJSON(&b); err != nil { ctx.AbortWithError(http.StatusBadRequest, err) return } res, err := c.Login(context.Background(), &pb.LoginRequest{ Email: b.Email, Password: b.Password, }) if err != nil { ctx.AbortWithError(http.StatusBadGateway, err) return } ctx.JSON(http.StatusCreated, &res) } |
认证微服务客户端
需要进行拨号来与认证微服务进行通讯。别忘了我们在config.go
文件中初始化了环境文件中所存储的微服务URL。下面就来访问这一数据。
下面编写pkg/auth/client.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 |
package auth import ( "fmt" "go-grpc-api-gateway/pkg/auth/pb" "go-grpc-api-gateway/pkg/config" "google.golang.org/grpc" ) type ServiceClient struct { Client pb.AuthServiceClient } func InitServiceClient(c *config.Config) pb.AuthServiceClient { // using WithInsecure() because no SSL running cc, err := grpc.Dial(c.AuthSvcUrl, grpc.WithInsecure()) if err != nil { fmt.Println("Could not connect:", err) } return pb.NewAuthServiceClient(cc) } |
认证中间件
我们需要拦截掉对商品和订单微服务的未认证请求。也就是说,对于某些路由我们只允许登录用户来访问受保护的微服务。
其实很简单,通过HTTP请求头获取到JWT令牌,然后通过认证微服务来验证这个令牌。我们此前已在auth.proto
文件中定义了validate
端点。
如果验证令牌正确,就让请求通过。如不正确,则抛出未认证的HTTP错误。
在pkg/auth/middleware.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 |
package auth import ( "context" "net/http" "strings" "go-grpc-api-gateway/pkg/auth/pb" "github.com/gin-gonic/gin" ) type AuthMiddlewareConfig struct { svc *ServiceClient } func InitAuthMiddleware(svc *ServiceClient) AuthMiddlewareConfig { return AuthMiddlewareConfig{svc} } func (c *AuthMiddlewareConfig) AuthRequired(ctx *gin.Context) { authorization := ctx.Request.Header.Get("authorization") if authorization == "" { ctx.AbortWithStatus(http.StatusUnauthorized) return } token := strings.Split(authorization, "Bearer ") if len(token) < 2 { ctx.AbortWithStatus(http.StatusUnauthorized) return } res, err := c.svc.Client.Validate(context.Background(), &pb.ValidateRequest{ Token: token[1], }) if err != nil || res.Status != http.StatusOK { ctx.AbortWithStatus(http.StatusUnauthorized) return } ctx.Set("userId", res.UserId) ctx.Next() } |
初始化路由
要访问刚刚编写的路由,需要先行注册。
在pkg/auth/routes.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 |
package auth import ( "go-grpc-api-gateway/pkg/auth/routes" "go-grpc-api-gateway/pkg/config" "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.Engine, c *config.Config) *ServiceClient { svc := &ServiceClient{ Client: InitServiceClient(c), } routes := r.Group("/auth") routes.POST("/register", svc.Register) routes.POST("/login", svc.Login) return svc } func (svc *ServiceClient) Register(ctx *gin.Context) { routes.Register(ctx, svc.Client) } func (svc *ServiceClient) Login(ctx *gin.Context) { routes.Login(ctx, svc.Client) } |
订单微服务的端点
现在需要对订单微服务做同样的操作。
创建订单路由
该路由也类似于上面编写的注册和登录路由。获取HTTP请求体,再将数据转发给订单微服务。
在pkg/order/routes/create_order.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 |
package routes import ( "context" "net/http" "go-grpc-api-gateway/pkg/order/pb" "github.com/gin-gonic/gin" ) type CreateOrderRequestBody struct { ProductId int64 `json:"productId"` Quantity int64 `json:"quantity"` } func CreateOrder(ctx *gin.Context, c pb.OrderServiceClient) { body := CreateOrderRequestBody{} if err := ctx.BindJSON(&body); err != nil { ctx.AbortWithError(http.StatusBadRequest, err) return } userId, _ := ctx.Get("userId") res, err := c.CreateOrder(context.Background(), &pb.CreateOrderRequest{ ProductId: body.ProductId, Quantity: body.Quantity, UserId: userId.(int64), }) if err != nil { ctx.AbortWithError(http.StatusBadGateway, err) return } ctx.JSON(http.StatusCreated, &res) } |
订单微服务客户端
订单微服务也需要一个客户端。
在pkg/order/client.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 |
package order import ( "fmt" "go-grpc-api-gateway/pkg/config" "go-grpc-api-gateway/pkg/order/pb" "google.golang.org/grpc" ) type ServiceClient struct { Client pb.OrderServiceClient } func InitServiceClient(c *config.Config) pb.OrderServiceClient { // using WithInsecure() because no SSL running cc, err := grpc.Dial(c.OrderSvcUrl, grpc.WithInsecure()) if err != nil { fmt.Println("Could not connect:", err) } return pb.NewOrderServiceClient(cc) } |
初始化路由
需要先进行注册才能访问刚刚编写的路由。
在pkg/order/routes.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 |
package order import ( "go-grpc-api-gateway/pkg/auth" "go-grpc-api-gateway/pkg/config" "go-grpc-api-gateway/pkg/order/routes" "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.Engine, c *config.Config, authSvc *auth.ServiceClient) { a := auth.InitAuthMiddleware(authSvc) svc := &ServiceClient{ Client: InitServiceClient(c), } routes := r.Group("/order") routes.Use(a.AuthRequired) routes.POST("/", svc.CreateOrder) } func (svc *ServiceClient) CreateOrder(ctx *gin.Context) { routes.CreateOrder(ctx, svc.Client) } |
商品微服务的端点
创建商品的路由
该路由也类似前述的路由。
在pkg/product/routes/create_product.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 |
package routes import ( "context" "net/http" "go-grpc-api-gateway/pkg/product/pb" "github.com/gin-gonic/gin" ) type CreateProductRequestBody struct { Name string `json:"name"` Stock int64 `json:"stock"` Price int64 `json:"price"` } func CreateProduct(ctx *gin.Context, c pb.ProductServiceClient) { body := CreateProductRequestBody{} if err := ctx.BindJSON(&body); err != nil { ctx.AbortWithError(http.StatusBadRequest, err) return } res, err := c.CreateProduct(context.Background(), &pb.CreateProductRequest{ Name: body.Name, Stock: body.Stock, Price: body.Price, }) if err != nil { ctx.AbortWithError(http.StatusBadGateway, err) return } ctx.JSON(http.StatusCreated, &res) } |
查找单个商品的路由
这是我们首次从路由中获取参数。我们很快就会在URL中定义该参数。但这里我们先获取这个参数id
,然后将字符串转换为数字,原因是我们在product.proto
中定义的是整型。
在pkg/product/routes/find_one.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 |
package routes import ( "context" "net/http" "strconv" "go-grpc-api-gateway/pkg/product/pb" "github.com/gin-gonic/gin" ) func FineOne(ctx *gin.Context, c pb.ProductServiceClient) { id, _ := strconv.ParseInt(ctx.Param("id"), 10, 32) res, err := c.FindOne(context.Background(), &pb.FindOneRequest{ Id: int64(id), }) if err != nil { ctx.AbortWithError(http.StatusBadGateway, err) return } ctx.JSON(http.StatusCreated, &res) } |
我们没有为product.proto
中定义的DecreaseStock
端点创建路由。这是因为该端点无法在API网关中直接访问。在系列文章第2部分中我们会编写调用订单微服务中该端点的代码。
商品微服务客户端
同样我们需要定义与商品微服务通讯的客户端。
在pkg/product/client.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 |
package product import ( "fmt" "go-grpc-api-gateway/pkg/config" "go-grpc-api-gateway/pkg/product/pb" "google.golang.org/grpc" ) type ServiceClient struct { Client pb.ProductServiceClient } func InitServiceClient(c *config.Config) pb.ProductServiceClient { // using WithInsecure() because no SSL running cc, err := grpc.Dial(c.ProductSvcUrl, grpc.WithInsecure()) if err != nil { fmt.Println("Could not connect:", err) } return pb.NewProductServiceClient(cc) } |
初始化路由
同样我们需要注册刚刚创建的路由。
在pkg/product/routes.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 product import ( "go-grpc-api-gateway/pkg/auth" "go-grpc-api-gateway/pkg/config" "go-grpc-api-gateway/pkg/product/routes" "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.Engine, c *config.Config, authSvc *auth.ServiceClient) { a := auth.InitAuthMiddleware(authSvc) svc := &ServiceClient{ Client: InitServiceClient(c), } routes := r.Group("/product") routes.Use(a.AuthRequired) routes.POST("/", svc.CreateProduct) routes.GET("/:id", svc.FindOne) } func (svc *ServiceClient) FindOne(ctx *gin.Context) { routes.FineOne(ctx, svc.Client) } func (svc *ServiceClient) CreateProduct(ctx *gin.Context) { routes.CreateProduct(ctx, svc.Client) } |
Main文件
最后还有一件重要的事,我们需要启动应用。前面已经注册了路由,现在需要在启动应用时调用这些注册的代码。
在cmd/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 |
package main import ( "log" "go-grpc-api-gateway/pkg/auth" "go-grpc-api-gateway/pkg/config" "go-grpc-api-gateway/pkg/order" "go-grpc-api-gateway/pkg/product" "github.com/gin-gonic/gin" ) func main() { c, err := config.LoadConfig() if err != nil { log.Fatalln("Failed at config", err) } r := gin.Default() authSvc := *auth.RegisterRoutes(r, &c) product.RegisterRoutes(r, &c, &authSvc) order.RegisterRoutes(r, &c, &authSvc) r.Run(c.Port) } |
API网关至此就大功告成了!
1 2 |
$ make server |
命令地终端中的效果如下:
下面我们来开发第一个微服务。
认证微服务 (go-grpc-auth-svc)
这是要编写的三个微服务中的第一个。本文已经很长了,所以会尽量保持简洁。像环境变量这样的处理方式非常类似。
请 在命令行终端中进入
go-grpc-auth-svc
初始化项目
1 2 |
$ go mod init go-grpc-auth-svc |
安装模块
1 2 3 4 5 6 7 |
$ go get github.com/spf13/viper $ go get google.golang.org/grpc $ go get gorm.io/gorm $ go get gorm.io/driver/mysql $ go get golang.org/x/crypto/bcrypt $ go get github.com/golang-jwt/jwt/v4 |
项目结构
我们需要先配置项目。认证微服务相较API网关要更为轻量。
文件夹
1 2 |
$ mkdir -p cmd pkg/config/envs pkg/db pkg/models pkg/pb pkg/services pkg/utils |
文件
1 2 3 |
$ touch Makefile cmd/main.go pkg/config/envs/dev.env pkg/config/config.go $ touch pkg/pb/auth.proto pkg/db/db.go pkg/models/auth.go pkg/services/auth.go pkg/utils/hash.go pkg/utils/jwt.go |
项目结构如下图所示:
Makefile
这里我们同样需要Makefile
来简化输入的命令。
在Makefile
中加入如下代码:
1 2 3 4 5 6 |
proto: protoc pkg/pb/*.proto --go_out=. --go-grpc_out=. server: go run cmd/main.go |
Proto文件
在微服务端及API网关两端都需要proto文件。
在pkg/pb/auth.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 45 46 47 |
syntax = "proto3"; package auth; option go_package = "./pkg/pb"; service AuthService { rpc Register(RegisterRequest) returns (RegisterResponse) {} rpc Login(LoginRequest) returns (LoginResponse) {} rpc Validate(ValidateRequest) returns (ValidateResponse) {} } // Register message RegisterRequest { string email = 1; string password = 2; } message RegisterResponse { int64 status = 1; string error = 2; } // Login message LoginRequest { string email = 1; string password = 2; } message LoginResponse { int64 status = 1; string error = 2; string token = 3; } // Validate message ValidateRequest { string token = 1; } message ValidateResponse { int64 status = 1; string error = 2; int64 userId = 3; } |
生成Protobuf文件
需要生成protobuf文件。
1 2 |
$ make proto |
环境变量
这里需要的变量有gRPC服务端端口、数据库的URL和JWT所用到的密钥。
在pkg/config/envs/dev.env
中加入如下代码:
1 2 3 4 |
PORT=:50051 DB_URL=<USER>:<PASSWORD>@tcp(<HOST>:<PORT>)/auth_svc?charset=utf8mb4&parseTime=True&loc=Local JWT_SECRET_KEY=r43t18sc |
配置
我们需要为该微服务创建一个config.go
文件。
在pkg/config/config.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 |
package config import "github.com/spf13/viper" type Config struct { Port string `mapstructure:"PORT"` DBUrl string `mapstructure:"DB_URL"` JWTSecretKey string `mapstructure:"JWT_SECRET_KEY"` } func LoadConfig() (config Config, err error) { viper.AddConfigPath("./pkg/config/envs") viper.SetConfigName("dev") viper.SetConfigType("env") viper.AutomaticEnv() err = viper.ReadInConfig() if err != nil { return } err = viper.Unmarshal(&config) return } |
数据模型
在这个模型中,我们在auth_svc
数据库中创建一张表。此前已经在MySQL中创建过数据库auth_svc
。
在pkg/models/auth.go
中添加如下代码:
1 2 3 4 5 6 7 8 9 |
package models type User struct { Id int64 `json:"id" gorm:"primaryKey"` Email string `json:"email"` Password string `json:"password"` } |
数据库连接
下面来连接数据库。
db.AutoMigrate
方法会在启用应用时自动创建数据表。
在pkg/db/db.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 |
package db import ( "log" "go-grpc-auth-svc/pkg/models" "gorm.io/driver/mysql" "gorm.io/gorm" ) type Handler struct { DB *gorm.DB } func Init(url string) Handler { db, err := gorm.Open(mysql.Open(url), &gorm.Config{}) if err != nil { log.Fatalln(err) } db.AutoMigrate(&models.User{}) return Handler{db} } |
Hash帮助函数
在该文件中,有两个函数,一个用于通过bcrypt对密码加密,另一个用于密码验证。
在pkg/utils/hash.go
中加入如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package utils import "golang.org/x/crypto/bcrypt" func HashPassword(password string) string { bytes, _ := bcrypt.GenerateFromPassword([]byte(password), 5) return string(bytes) } func CheckPasswordHash(password string, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } |
JWT帮助函数
这里我们根据dev.env
文件中定义的密钥来生成和校验JWT令牌。
在pkg/utils/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 64 65 66 67 68 69 70 71 72 |
package utils import ( "errors" "time" "go-grpc-auth-svc/pkg/models" "github.com/golang-jwt/jwt/v4" ) type JwtWrapper struct { SecretKey string Issuer string ExpirationHours int64 } type jwtClaims struct { jwt.StandardClaims Id int64 Email string } func (w *JwtWrapper) GenerateToken(user models.User) (signedToken string, err error) { claims := &jwtClaims{ Id: user.Id, Email: user.Email, StandardClaims: jwt.StandardClaims{ ExpiresAt: time.Now().Local().Add(time.Hour * time.Duration(w.ExpirationHours)).Unix(), Issuer: w.Issuer, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signedToken, err = token.SignedString([]byte(w.SecretKey)) if err != nil { return "", err } return signedToken, nil } func (w *JwtWrapper) ValidateToken(signedToken string) (claims *jwtClaims, err error) { token, err := jwt.ParseWithClaims( signedToken, &jwtClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(w.SecretKey), nil }, ) if err != nil { return } claims, ok := token.Claims.(*jwtClaims) if !ok { return nil, errors.New("Couldn't parse claims") } if claims.ExpiresAt < time.Now().Local().Unix() { return nil, errors.New("JWT is expired") } return claims, nil } |
Auth服务
这里我们编写Auth微服务的业务逻辑。在API网关中创建的认证路由会将请求转发给该文件。
在pkg/services/auth.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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
package services import ( "context" "net/http" "go-grpc-auth-svc/pkg/db" "go-grpc-auth-svc/pkg/models" "go-grpc-auth-svc/pkg/pb" "go-grpc-auth-svc/pkg/utils" ) type Server struct { H db.Handler Jwt utils.JwtWrapper pb.UnimplementedAuthServiceServer } func (s *Server) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) { var user models.User if result := s.H.DB.Where(&models.User{Email: req.Email}).First(&user); result.Error == nil { return &pb.RegisterResponse{ Status: http.StatusConflict, Error: "E-Mail already exists", }, nil } user.Email = req.Email user.Password = utils.HashPassword(req.Password) s.H.DB.Create(&user) return &pb.RegisterResponse{ Status: http.StatusCreated, }, nil } func (s *Server) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) { var user models.User if result := s.H.DB.Where(&models.User{Email: req.Email}).First(&user); result.Error != nil { return &pb.LoginResponse{ Status: http.StatusNotFound, Error: "User not found", }, nil } match := utils.CheckPasswordHash(req.Password, user.Password) if !match { return &pb.LoginResponse{ Status: http.StatusNotFound, Error: "User not found", }, nil } token, _ := s.Jwt.GenerateToken(user) return &pb.LoginResponse{ Status: http.StatusOK, Token: token, }, nil } func (s *Server) Validate(ctx context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) { claims, err := s.Jwt.ValidateToken(req.Token) if err != nil { return &pb.ValidateResponse{ Status: http.StatusBadRequest, Error: err.Error(), }, nil } var user models.User if result := s.H.DB.Where(&models.User{Email: claims.Email}).First(&user); result.Error != nil { return &pb.ValidateResponse{ Status: http.StatusNotFound, Error: "User not found", }, nil } return &pb.ValidateResponse{ Status: http.StatusOK, UserId: user.Id, }, nil } |
Main文件
最后需要启动该微服务。
我们来编写cmd/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 45 46 47 48 49 50 51 52 53 54 |
package main import ( "fmt" "log" "net" "go-grpc-auth-svc/pkg/config" "go-grpc-auth-svc/pkg/db" "go-grpc-auth-svc/pkg/pb" "go-grpc-auth-svc/pkg/services" "go-grpc-auth-svc/pkg/utils" "google.golang.org/grpc" ) func main() { c, err := config.LoadConfig() if err != nil { log.Fatalln("Failed at config", err) } h := db.Init(c.DBUrl) jwt := utils.JwtWrapper{ SecretKey: c.JWTSecretKey, Issuer: "go-grpc-auth-svc", ExpirationHours: 24 * 365, } lis, err := net.Listen("tcp", c.Port) if err != nil { log.Fatalln("Failed to listing:", err) } fmt.Println("Auth Svc on", c.Port) s := services.Server{ H: h, Jwt: jwt, } grpcServer := grpc.NewServer() pb.RegisterAuthServiceServer(grpcServer, &s) if err := grpcServer.Serve(lis); err != nil { log.Fatalln("Failed to serve:", err) } } |
终于告一段落了,第一部分中的API网关和认证微服务就此完成。
下面运行服务。
1 2 |
$ make server |
终端中的输出如下:
测试认证微服务和API网关
现在我们向API网关发送两个HTTP请求。要确保两个应用都进行了启动。API网关的端口是3000,认证微服务的端口是52001。
注册新用户
1 2 3 4 5 6 7 8 |
curl --request POST \ --url http://localhost:3000/auth/register \ --header 'Content-Type: application/json' \ --data '{ "email": "elon@musk.com", "password": "1234567" }' |
用户登录
1 2 3 4 5 6 7 8 |
curl --request POST \ --url http://localhost:3000/auth/login \ --header 'Content-Type: application/json' \ --data '{ "email": "elon@musk.com", "password": "1234567" }' |
这个请求的响应非常重要,因为该请求会返回JWT令牌,在第二部分其它微服务中会使用到。
响应内容如下:
1 2 |
{"status":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTA0NTg2NDMsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MSwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.rtmWUnxTR5pFycFbRy1C5S5oVrs2Nkt0sYO4QIsykFg"} |
整理自Kevin Vogel的文章。
后记
阅读英文原文的请注意可能会有如下错误
- protoc-gen-go: plugins are not supported
1 2 |
--go_out: protoc-gen-go: plugins are not supported; use 'protoc --go-grpc_out=...' to generate gRPC |
解决方法是替换掉Makefile中的相应内容
1 2 3 4 |
proto: protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=. |
- grpc with mustEmbedUnimplemented*** method
方法一
1 2 3 4 5 6 7 |
type Server struct { H db.Handler Jwt utils.JwtWrapper pb.UnimplementedAuthServiceServer } |
方法二
1 |
--go-grpc_out=require_ unimplemented_ servers=false |