使用Go语言创建3个微服务和1个API网关 (2022版)

我们会一起开发3个微服务和1个处理HTTP请求的API网关。HTTP请求会通过gRPC转发给这些微服务。此外还会同时处理JWT验证。

本文由两部分组成,第二部分请点击这里。

我们要创建什么应用呢?

我们会一起构建一套小型电商系统,项目使用Go语言进行开发。

[图片上传失败...(image-a2f9dd-1658928453062)]

应用架构

本系列文章分成两个部分:

  1. API网关: 处理HTTP请求
  2. Auth服务: 提供注册登录及通过JWT生成Token等功能
  3. Product服务: 提供添加商品减库存查找商品等服务
  4. Order服务: 该微服务中我们只提供了创建订单功能

每个微服务都是独立的项目。

因本系列文章阅读较为耗时,所以尽可能地让这些微服务保持简洁。我们不会涉及Docker或超时问题。

学习完本部分后,读者可以使用API网关和其中一个微服务新建用户。但要求对Go和gRPC有一定的基础。

下面开干!

创建数据库
$ psql postgres
$ CREATE DATABASE auth_svc;
$ CREATE DATABASE order_svc;
$ CREATE DATABASE product_svc;
$ \l
$ \q
\l

[图片上传失败...(image-e678b1-1658928453062)]

对PostgreSQL不熟悉的用户也不必担心,使用MySQL完全没有问题,考虑到国内大部分开发者更习惯使用MySQL,后续代码将使用MySQL,其实使用GORM对开发而言两者的差别并不大。

$ mysql -uroot -p
$ CREATE DATABASE auth_svc;
$ CREATE DATABASE order_svc;
$ CREATE DATABASE product_svc;
$ SHOW DATABASES;
$ exit
创建项目

首先我们需要创建项目。推荐创建一个存储Go项目的工作目录。入口目录读者可自行选定。

注:本文中有大量的创建目录和切换目录等命令,Windows的用户可使用Git Bash或直接在IDE中创建对应的目录

$ 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网关。

$ cd go-grpc-api-gateway
API网关

从API网关讲起可能会略显无趣,但在完成后就可以将微服务结合API网关直接进行测试了。

初始化项目

$ go mod init go-grpc-api-gateway

安装模块

$ go get github.com/gin-gonic/gin
$ go get github.com/spf13/viper
$ go get google.golang.org/grpc

项目结构

我们需要设置整个项目。我个人偏好在一开始就创建好所有目录和文件。有挺多目录文件,请坐稳了。

文件夹

$ mkdir -p cmd pkg/config/envs pkg/auth/pb pkg/auth/routes pkg/order/pb pkg/order/routes pkg/product/pb pkg/product/routes

文件

$ 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

项目结构如下图所示:

[图片上传失败...(image-8d2f3f-1658928453062)]

以上使用的是VS Code,如果希望显示同样的图标,请安装Material Icon Theme插件。下面就开始写代码吧。

Protobuf文件

首先我们需要分别为3个微服务添加Protobuf文件。

认证微服务的Proto

第一个protobuf文件用于构建认证微服务。可以看到我们会定义三个端点

  • Register
  • Login
  • Validate (JSON Web Token)
pkg/auth/pb/auth.proto
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
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
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;
  string sku = 2;
  int64 stock = 3;
  int64 price = 4;
}

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
proto:
    protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=.

server:
    go run cmd/main.go

现在我们就可以通过刚刚创建的proto文件来生成protobuf文件了。

$ make proto

控制台中的输出应该也非常简单:

$ make proto
protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=.
.proto

[图片上传失败...(image-52d7e0-1658928453062)]

环境变量

我们需要定义一些环境变量。

pkg/config/envs/dev.env
PORT=:3000
AUTH_SVC_URL=localhost:50051
PRODUCT_SVC_URL=localhost:50052
ORDER_SVC_URL=localhost:50053

配置

在这一文件中,我们将环境文件中的数据拉给到API网关。

pkg/config/config.go
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
pkg/auth/routes/register.go

**注意! **在大部分Go文件中,需要将项目名称替换成你自己的。此处模块名位于第5行,请自行替换。

哈哈,其实大可不必担心,因为我在这里使用了本地路径~

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
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
pkg/auth/client.go
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)
}

认证中间件

我们需要拦截掉对商品和订单微服务的未认证请求。也就是说,对于某些路由我们只允许登录用户来访问受保护的微服务。

auth.protovalidate

如果验证令牌正确,就让请求通过。如不正确,则抛出未认证的HTTP错误。

pkg/auth/middleware.go
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
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
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
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
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
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)
}

查找单个商品的路由

idproduct.proto
pkg/product/routes/find_one.go
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.protoDecreaseStock

商品微服务客户端

同样我们需要定义与商品微服务通讯的客户端。

pkg/product/client.go
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
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
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网关至此就大功告成了!

$ make server

命令地终端中的效果如下:

[图片上传失败...(image-e544a4-1658928453062)]

下面我们来开发第一个微服务。

认证微服务 (go-grpc-auth-svc)

这是要编写的三个微服务中的第一个。本文已经很长了,所以会尽量保持简洁。像环境变量这样的处理方式非常类似。

go-grpc-auth-svc

初始化项目

$ go mod init go-grpc-auth-svc

安装模块Installing Modules

$ 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网关要更为轻量。

文件夹

$ mkdir -p cmd pkg/config/envs pkg/db pkg/models pkg/pb pkg/services pkg/utils

文件

$ 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

项目结构如下图所示:

[图片上传失败...(image-42e939-1658928453062)]

Makefile

Makefile
Makefile
proto:
    protoc pkg/pb/*.proto --go_out=. --go-grpc_out=.

server:
    go run cmd/main.go

Proto文件

在微服务端及API网关两端都需要proto文件。

pkg/pb/auth.proto
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文件。

$ make proto

环境变量

这里需要的变量有gRPC服务端端口、数据库的URL和JWT所用到的密钥。

pkg/config/envs/dev.env
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
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_svcauth_svc
pkg/models/auth.go
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
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
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
pkg/utils/jwt.go
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
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
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网关和认证微服务就此完成。

下面运行服务。

$ make server

终端中的输出如下:

[图片上传失败...(image-1f070d-1658928453062)]

测试认证微服务和API网关

现在我们向API网关发送两个HTTP请求。要确保两个应用都进行了启动。API网关的端口是3000,认证微服务的端口是52001。

注册新用户

curl --request POST \
  --url http://localhost:3000/auth/register \
  --header 'Content-Type: application/json' \
  --data '{
 "email": "elon@musk.com",
 "password": "1234567"
}'

用户登录

curl --request POST \
  --url http://localhost:3000/auth/login \
  --header 'Content-Type: application/json' \
  --data '{
 "email": "elon@musk.com",
 "password": "1234567"
}'

这个请求的响应非常重要,因为该请求会返回JWT令牌,在第二部分其它微服务中会使用到。

响应内容如下:

{"status":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTA0NTg2NDMsImlzcyI6ImdvLWdycGMtYXV0aC1zdmMiLCJJZCI6MSwiRW1haWwiOiJlbG9uQG11c2suY29tIn0.rtmWUnxTR5pFycFbRy1C5S5oVrs2Nkt0sYO4QIsykFg"}

整理自Kevin Vogel的文章。

后记

阅读英文原文的请注意可能会有如下错误

  1. protoc-gen-go: plugins are not supported
--go_out: protoc-gen-go: plugins are not supported; use 'protoc --go-grpc_out=...' to generate gRPC

解决方法是替换掉Makefile中的相应内容

proto:
    protoc pkg/**/pb/*.proto --go_out=. --go-grpc_out=.

  1. grpc with mustEmbedUnimplemented*** method
    方法一
type Server struct {
    H   db.Handler
    Jwt utils.JwtWrapper
    pb.UnimplementedAuthServiceServer
}

方法二

--go-grpc_out=require_ unimplemented_ servers=false