使用Go语言创建3个微服务和1个API网关 (2022版)
我们继续讲解。
商品微服务 (go-grpc-product-svc)这是三个微服务中的第二个。这里我们实现三个功能:
- 创建商品
- 根据ID查找某一商品
- 根据商品ID或订单ID减少商品库存
go-grpc-product-svc
项目初始化
go mod init go-grpc-product-svc
复制代码
安装模块
$ go get github.com/spf13/viper
$ go get google.golang.org/grpc
$ go get gorm.io/gorm
$ go get gorm.io/driver/mysql
复制代码
项目结构
我们需要配置整个项目。认证微服务相较API网关要精简很多。
目录
$ mkdir -p cmd pkg/config/envs pkg/db pkg/models pkg/pb pkg/services
复制代码
Files
$ 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
proto:
protoc pkg/pb/*.proto --go_out=. --go-grpc_out=.
server:
go run cmd/main.go
复制代码
Proto文件
CreateProductFindOneDecreaseStock
pkg/pb/product.proto
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文件:
$ make proto
复制代码
环境变量
pkg/config/envs/dev.env
PORT=:50052
DB_URL=<USER>:<PASSWORD>@tcp(<HOST>:<PORT>)/product_svc?charset=utf8mb4&parseTime=True&loc=Local
复制代码
配置
我们需要使用Viper模块初始化来加载这里环境变量。
pkg/config/config.go
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
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
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
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}
}
复制代码
商品服务
DecreaseStock
pkg/services/product.go
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
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)
}
}
复制代码
这时就可以使用下面命令运行应用了:
$ make server
复制代码
订单微服务(go-grpc-order-svc)
这是三个微服务中的最后一个。我们会添加一个功能。。
- 按用户ID和商品ID创建订单
go-grpc-order-svc
初始化项目
$ go mod init go-grpc-order-svc
复制代码
安装模块
$ go get github.com/spf13/viper
$ go get google.golang.org/grpc
$ go get gorm.io/gorm
$ go get gorm.io/driver/mysql
复制代码
项目结构
我们需要搭建项目。订单服务比API网关要简洁一些。
文件夹
$ mkdir -p cmd pkg/config/envs pkg/client pkg/db pkg/models pkg/pb pkg/services
复制代码
文件
$ 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
proto:
protoc pkg/pb/*.proto --go_out=. --go-grpc_out=.
server:
go run cmd/main.go
复制代码
订单Proto文件
pkg/pb/order.proto
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
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文件:
$ make proto
复制代码
环境文件
pkg/config/envs/dev.env
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
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
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
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
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
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
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个微服务,在相应的项目中运行命令:
$ make server
复制代码
测试所有端点
可以使用Insomnia、Postman等软件逐一测试,也可以像本文中一样使用cURL测试各端点。
首先需要注册一个用户:
$ curl --request POST \
--url http://localhost:3000/auth/register \
--header 'Content-Type: application/json' \
--data '{
"email": "elon@musk.com",
"password": "12345678"
}'
复制代码
登录
接下要进行登录来获取JSON Web Token:
$ curl --request POST \
--url http://localhost:3000/auth/login \
--header 'Content-Type: application/json' \
--data '{
"email": "elon@musk.com",
"password": "12345678"
}'
复制代码
这里的响应非常重要,因为后续的请求都需要使用响应中的token。
响应
{
"status":200,
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE"
}
复制代码
创建商品
此时我们需要在请求头中添加token来创建商品。
$ 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来查找商品。
$ curl --request GET \
--url http://localhost:3000/product/1 \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTI3OTk1MzgsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MiwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.-9zHeYgS-VHyvRoz5UXg6nMrNkJ1HU2vTfW13QlT2lE'
复制代码
创建订单
需要传递商品ID和数量来创建订单。
$ 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的文章。