使用Go语言创建3个微服务和1个API网关 (2022版)
本文是系列文章使用gRPC、API网关和权限校验创建Go微服务的下篇。
我们继续讲解。
商品微服务 (go-grpc-product-svc)
Github: https://github.com/alanhou/go-grpc-project
这是三个微服务中的第二个。这里我们实现三个功能:
- 创建商品
- 根据ID查找某一商品
- 根据商品ID或订单ID减少商品库存
请 在命令行终端进入
go-grpc-product-svc
项目初始化
1 |
go mod init go-grpc-product-svc |
安装模块
1 2 3 4 |
$ go get github.com/spf13/viper $ go get google.golang.org/grpc $ go get gorm.io/gorm $ go get gorm.io/driver/mysql |
项目结构
我们需要配置整个项目。认证微服务相较API网关要精简很多。
目录
1 |
$ mkdir -p cmd pkg/config/envs pkg/db pkg/models pkg/pb pkg/services |
Files
1 2 |
$ touch Makefile cmd/main.go pkg/config/envs/dev.env pkg/config/config.go $ touch pkg/pb/product.proto pkg/db/db.go pkg/models/stock_decrease_log.go pkg/models/product.go pkg/services/product.go |
项目结构如下所示:
Makefile
又到了快乐的编码时间了。老规矩,先编写Makefile来简化命令。
先在Makefile
中添加代码:
1 2 3 4 5 |
proto: protoc pkg/pb/*.proto --go_out=. --go-grpc_out=. server: go run cmd/main.go |
Proto文件
生成protobuf文件需要先编写一个商品的proto文件。我们声明了三个功能: CreateProduct
, FindOne
, DecreaseStock
在pkg/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 |
syntax = "proto3"; package product; option go_package = "./pkg/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; int64 stock = 3; int64 price = 4; } 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; } |
生成Protobuf文件
接下来使用下面的命令生成protobuf文件:
1 |
$ make proto |
环境变量
同样我们需要定义一些环境变量。在pkg/config/envs/dev.env
中添加如下代码:
1 2 |
PORT=:50052 DB_URL=<USER>:<PASSWORD>@tcp(<HOST>:<PORT>)/product_svc?charset=utf8mb4&parseTime=True&loc=Local |
配置
我们需要使用Viper模块初始化来加载这里环境变量。
在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 |
package config import "github.com/spf13/viper" type Config struct { Port string `mapstructure:"PORT"` DBUrl string `mapstructure:"DB_URL"` } 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 } |
减库存记录模型
这是我们唯一包含两个模型的微服务。出于幂等考虑我们需要记录下所有减少的库存。
什么是幂等?
幂等(Idempotence)是一种特性,可以保障同一运算的调用不会导致服务状态的任何改变进而导致其它的副作用。
也就是说,我们要确保库存只减少一次。设想一下如果出于某种原因同一订单中库存单位减少了两次,就会导致数据的不一致性。
在pkg/models/stock_decrease_log.go
中添加如下代码:
1 2 3 4 5 6 7 |
package models type StockDecreaseLog struct { Id int64 `json:"id" gorm:"primaryKey"` OrderId int64 `json:"order_id"` ProductRefer int64 `json:"product_id"` } |
商品模型
然后我们需要添加商品模型。在pkg/models/product.go
中添加如下代码:
1 2 3 4 5 6 7 8 9 |
package models type Product struct { Id int64 `json:"id" gorm:"primaryKey"` Name string `json:"name"` Stock int64 `json:"stock"` Price int64 `json:"price"` StockDecreaseLogs StockDecreaseLog `gorm:"foreignKey:ProductRefer"` } |
数据库连接
我们还要像第一部分中一样连接数据库。在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 |
package db import ( "go-grpc-product-svc/pkg/models" "log" "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.Product{}) db.AutoMigrate(&models.StockDecreaseLog{}) return Handler{db} } |
商品服务
此处我们需要处理所有进入的gRPC请求。从第60行开始的DecreaseStock
函数值得注意,我们检查了特定订单ID已减去的库存来保障数据的一致性,避免不小心连续两次调了同一端点。这是微服务中非常重要的课题。
在pkg/services/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 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 92 93 94 95 96 97 98 |
package services import ( "context" "net/http" "go-grpc-product-svc/pkg/db" "go-grpc-product-svc/pkg/models" pb "go-grpc-product-svc/pkg/pb" ) type Server struct { H db.Handler pb.UnimplementedProductServiceServer } func (s *Server) CreateProduct(ctx context.Context, req *pb.CreateProductRequest) (*pb.CreateProductResponse, error) { var product models.Product product.Name = req.Name product.Stock = req.Stock product.Price = req.Price if result := s.H.DB.Create(&product); result.Error != nil { return &pb.CreateProductResponse{ Status: http.StatusConflict, Error: result.Error.Error(), }, nil } return &pb.CreateProductResponse{ Status: http.StatusCreated, Id: product.Id, }, nil } func (s *Server) FindOne(ctx context.Context, req *pb.FindOneRequest) (*pb.FindOneResponse, error) { var product models.Product if result := s.H.DB.First(&product, req.Id); result.Error != nil { return &pb.FindOneResponse{ Status: http.StatusNotFound, Error: result.Error.Error(), }, nil } data := &pb.FindOneData{ Id: product.Id, Name: product.Name, Stock: product.Stock, Price: product.Price, } return &pb.FindOneResponse{ Status: http.StatusOK, Data: data, }, nil } func (s *Server) DecreaseStock(ctx context.Context, req *pb.DecreaseStockRequest) (*pb.DecreaseStockResponse, error) { var product models.Product if result := s.H.DB.First(&product, req.Id); result.Error != nil { return &pb.DecreaseStockResponse{ Status: http.StatusNotFound, Error: result.Error.Error(), }, nil } if product.Stock <= 0 { return &pb.DecreaseStockResponse{ Status: http.StatusConflict, Error: "Stock too low", }, nil } var log models.StockDecreaseLog if result := s.H.DB.Where(&models.StockDecreaseLog{OrderId: req.OrderId}).First(&log); result.Error == nil { return &pb.DecreaseStockResponse{ Status: http.StatusConflict, Error: "Stock already decreased", }, nil } product.Stock = product.Stock - 1 s.H.DB.Save(&product) log.OrderId = req.OrderId log.ProductRefer = product.Id s.H.DB.Create(&log) return &pb.DecreaseStockResponse{ Status: http.StatusOK, }, 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 |
package main import ( "fmt" "log" "net" "go-grpc-product-svc/pkg/config" "go-grpc-product-svc/pkg/db" pb "go-grpc-product-svc/pkg/pb" services "go-grpc-product-svc/pkg/services" "google.golang.org/grpc" ) func main() { c, err := config.LoadConfig() if err != nil { log.Fatalln("Failed at config", err) } h := db.Init(c.DBUrl) lis, err := net.Listen("tcp", c.Port) if err != nil { log.Fatalln("Failed to listing:", err) } fmt.Println("Product Svc on", c.Port) s := services.Server{ H: h, } grpcServer := grpc.NewServer() pb.RegisterProductServiceServer(grpcServer, &s) if err := grpcServer.Serve(lis); err != nil { log.Fatalln("Failed to serve:", err) } } |
这时就可以使用下面命令运行应用了:
1 2 |
$ <span class="hljs-built_in">make</span> server |
订单微服务(go-grpc-order-svc)
Github: https://github.com/alanhou/go-grpc-project
这是三个微服务中的最后一个。我们会添加一个功能。。
- 按用户ID和商品ID创建订单
请在终端中进入
go-grpc-order-svc
目录执行后续操作!
初始化项目
1 |
$ go mod init go-grpc-order-svc |
安装模块
1 2 3 4 |
$ go get github.com/spf13/viper $ go get google.golang.org/grpc $ go get gorm.io/gorm $ go get gorm.io/driver/mysql |
项目结构
我们需要搭建项目。订单服务比API网关要简洁一些。
文件夹
1 |
$ mkdir -p cmd pkg/config/envs pkg/client pkg/db pkg/models pkg/pb pkg/services |
文件
1 2 |
$ touch Makefile cmd/main.go pkg/config/envs/dev.env pkg/config/config.go $ touch pkg/pb/product.proto pkg/pb/order.proto pkg/db/db.go pkg/models/order.go pkg/services/order.go pkg/client/product_client.go |
文件结构如下所示:
Makefile
同样需要编写Makefile文件。
在Makefile
中加入如下代码:
1 2 3 4 5 |
proto: protoc pkg/pb/*.proto --go_out=. --go-grpc_out=. server: go run cmd/main.go |
订单Proto文件
在pkg/pb/order.proto
中添加如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
syntax = "proto3"; package order; option go_package = "./pkg/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/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 |
syntax = "proto3"; package product; option go_package = "./pkg/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; int64 stock = 3; int64 price = 4; } 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; } |
生成Protobuf文件
老规矩,运行以下命令生成两个protobuf文件:
1 |
$ make proto |
环境文件
在pkg/config/envs/dev.env
中添加如下代码:
1 2 3 |
PORT=:50053 DB_URL=<USER>:<PASSWORD>@tcp(<HOST>:<PORT>)/order_svc?charset=utf8mb4&parseTime=True&loc=Local PRODUCT_SVC_URL=localhost:50052 |
配置
在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 |
package config import "github.com/spf13/viper" type Config struct { Port string `mapstructure:"PORT"` DBUrl string `mapstructure:"DB_URL"` ProductSvcUrl string `mapstructure:"PRODUCT_SVC_URL"` } 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 } |
订单模型
在pkg/models/order.go
中添加代码:
1 2 3 4 5 6 7 8 |
package models type Order struct { Id int64 `json:"id" gorm:"primaryKey"` Price int64 `json:"price"` ProductId int64 `json:"product_id"` UserId int64 `json:"user_id"` } |
数据库连接
在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 |
package db import ( "go-grpc-order-svc/pkg/models" "log" "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.Order{}) return Handler{db} } |
商品微服务客户端
前面提到我们需要连接商品微服务。这就要创建一个客户端。
在pkg/client/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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
package client import ( "context" "fmt" "go-grpc-order-svc/pkg/pb" "google.golang.org/grpc" ) type ProductServiceClient struct { Client pb.ProductServiceClient } func InitProductServiceClient(url string) ProductServiceClient { cc, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { fmt.Println("Could not connect:", err) } c := ProductServiceClient{ Client: pb.NewProductServiceClient(cc), } return c } func (c *ProductServiceClient) FindOne(productId int64) (*pb.FindOneResponse, error) { req := &pb.FindOneRequest{ Id: productId, } return c.Client.FindOne(context.Background(), req) } func (c *ProductServiceClient) DecreaseStock(productId int64, orderId int64) (*pb.DecreaseStockResponse, error) { req := &pb.DecreaseStockRequest{ Id: productId, OrderId: orderId, } return c.Client.DecreaseStock(context.Background(), req) } |
订单服务
在pkg/services/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 42 43 44 45 46 47 48 49 50 51 52 |
package services import ( "context" "net/http" "go-grpc-order-svc/pkg/client" "go-grpc-order-svc/pkg/db" "go-grpc-order-svc/pkg/models" "go-grpc-order-svc/pkg/pb" ) type Server struct { H db.Handler ProductSvc client.ProductServiceClient pb.UnimplementedOrderServiceServer } func (s *Server) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) { product, err := s.ProductSvc.FindOne(req.ProductId) if err != nil { return &pb.CreateOrderResponse{Status: http.StatusBadRequest, Error: err.Error()}, nil } else if product.Status >= http.StatusNotFound { return &pb.CreateOrderResponse{Status: product.Status, Error: product.Error}, nil } else if product.Data.Stock < req.Quantity { return &pb.CreateOrderResponse{Status: http.StatusConflict, Error: "Stock too less"}, nil } order := models.Order{ Price: product.Data.Price, ProductId: product.Data.Id, UserId: req.UserId, } s.H.DB.Create(&order) res, err := s.ProductSvc.DecreaseStock(req.ProductId, order.Id) if err != nil { return &pb.CreateOrderResponse{Status: http.StatusBadRequest, Error: err.Error()}, nil } else if res.Status == http.StatusConflict { s.H.DB.Delete(&models.Order{}, order.Id) return &pb.CreateOrderResponse{Status: http.StatusConflict, Error: res.Error}, nil } return &pb.CreateOrderResponse{ Status: http.StatusCreated, Id: order.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 |
package main import ( "fmt" "log" "net" "go-grpc-order-svc/pkg/client" "go-grpc-order-svc/pkg/config" "go-grpc-order-svc/pkg/db" "go-grpc-order-svc/pkg/pb" "go-grpc-order-svc/pkg/services" "google.golang.org/grpc" ) func main() { c, err := config.LoadConfig() if err != nil { log.Fatalln("Failed at config", err) } h := db.Init(c.DBUrl) lis, err := net.Listen("tcp", c.Port) if err != nil { log.Fatalln("Failed to listing:", err) } productSvc := client.InitProductServiceClient(c.ProductSvcUrl) if err != nil { log.Fatalln("Failed to listing:", err) } fmt.Println("Order Svc on", c.Port) s := services.Server{ H: h, ProductSvc: productSvc, } grpcServer := grpc.NewServer() pb.RegisterOrderServiceServer(grpcServer, &s) if err := grpcServer.Serve(lis); err != nil { log.Fatalln("Failed to serve:", err) } } |
太棒了!我们已经完成了所有的微服务以及API网关。下面就来进行全面测试。但首先要确保已经启动了API网关和所有这3个微服务,在相应的项目中运行命令:
1 |
$ make server |
测试所有端点
可以使用Insomnia、Postman等软件逐一测试,也可以像本文中一样使用cURL测试各端点。
首先需要注册一个用户:
1 2 3 4 5 6 7 |
$ curl --request POST \ --url http://localhost:3000/auth/register \ --header 'Content-Type: application/json' \ --data '{ "email": "elon@musk.com", "password": "12345678" }' |
登录
接下要进行登录来获取JSON Web Token:
1 2 3 4 5 6 7 |
$ curl --request POST \ --url http://localhost:3000/auth/login \ --header 'Content-Type: application/json' \ --data '{ "email": "elon@musk.com", "password": "12345678" }' |
这里的响应非常重要,因为后续的请求都需要使用响应中的token。
响应
1 2 3 4 |
{ "status":200, "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE" } |
创建商品
此时我们需要在请求头中添加token来创建商品。
1 2 3 4 5 6 7 8 9 |
$ curl --request POST \ --url http://localhost:3000/product/ \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE' \ --header 'Content-Type: application/json' \ --data '{ "name": "Product A", "stock": 5, "price": 15 }' |
查找商品
需要在URL中添加商品ID来查找商品。
1 2 3 |
$ curl --request GET \ --url http://localhost:3000/product/1 \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE' |
创建订单
需要传递商品ID和数量来创建订单。
1 2 3 4 5 6 7 8 |
$ curl --request POST \ --url http://localhost:3000/order/ \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE' \ --header 'Content-Type: application/json' \ --data '{ "productId": 1, "quantity": 1 }' |
恭喜你成功了!
感谢阅读本系列有关如何使用Go语言开发微服务的第二部分。真心希望读者能从中学到一些新知识。
加油!
整理自Kevin Vogel的文章。