go-zero + gorm 微服务框架搭建| 青训营

3,101 阅读8分钟

前言

在青训营学习了微服务相关课程后,我打算自己动手尝试编写一个用户微服务,计划选用go-zero + gorm的架构。话不多说,开始动手。

生成项目初始模板

go-zero及其脚手架goctl的安装在这里就不介绍了,官方文档给出了较为详细的教程,只要跟随操作即可。

首先使用goctl创建一个空白的rpc项目模板,运行命令行工具。可以自由调整命名风格,风格指南在go-zero.dev/docs/tutori…

goctl rpc new user --style goZero

完成后进入项目目录,先安装依赖

go mod tidy

补全示例接口

此时,还不能直接这个服务,因为go-zero项目模板默认配置了etcd服务发现,需要先删除这部分内容,

打开/etc/user.yaml,删除其中的3-7行,并增加一行Mode: dev

Name: user.rpc  
ListenOn: 0.0.0.0:8080
Mode: dev

接着打开internal/login/pingLogic.go,定位到文件末尾。这个接口是模板自带的存活检查,但是没有具体实现,现在会返回一个空json。增加一个Pong: "pong"键值来补全响应。

func (l *PingLogic) Ping(in *user.Request) (*user.Response, error) {  
    return &user.Response{  
        Pong: "pong",  
    }, nil  
}

尝试运行

现在运行go run user.go,已经能够正常编译和启动。当看到以下输出,说明微服务启动成功。

Starting rpc server at 0.0.0.0:8080...

我想要验证一下这个接口是否符合预期,但是rpc服务不像http服务一样可以直接打开浏览器访问,这里就需要使用面向rpc的请求工具:

grpcurl

github.com/fullstoryde…

github提供了多种安装方法,因为我们本地已经搭建了go开发环境,因此可以直接使用go install命令安装

go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest

尝试请求

现在命令行使用grpcurl请求我们的微服务:

grpcurl -plaintext 127.0.0.1:8080 user.User/Ping

其中userUser分别对应user.proto中定义的package nameservice name,而Ping是具体的方法。

你应该会得到如下的输出,代表服务正常工作。

image.png

定义更多接口

目前这个微服务只能实现最基础的存活探测功能。要给微服务添加更多功能,首先需要在proto文件中定义对应的接口。

proto文件现有的RequestResponse数据结构是针对Ping方法的,这里先将其重命名成PingRequestPingResponse

本文中仅演示简单的用户创建和用户信息获取功能,不设置额外的鉴权和token发放,相关内容我会在后面的文章补全。因此,新设两个接口CreateQueryById

syntax = "proto3";  
  
package user;  
option go_package = "./user";  
  
message PingRequest {  
    string ping = 1;  
}  
  
message PingResponse {  
    string pong = 1;  
}  
  
message CreateRequest {  
    string username = 1;  
    string password = 2;  
}  
  
message CreateResponse {  
    int64 user_id = 1;  
}  
  
message QueryByIdRequest {  
    int64 user_id = 1;  
}  
  
message QueryResponse {  
    int64 user_id = 1;  
    string username = 2;  
    bytes password = 12;  
    int64 created_at = 15;  
    int64 updated_at = 16;  
}  
  
service User {  
    rpc Ping(PingRequest) returns(PingResponse);  
    rpc Create(CreateRequest) returns(CreateResponse);  
    rpc QueryById(QueryByIdRequest) returns(QueryResponse);  
}

重新生成项目

在命令行中运行如下命令,这会生成新的项目文件。命令后的三个路径参数均指向根目录,这与新建项目时的默认设置一致。

注:重新生成模板时不会修改已有的文件,所以pingLogic.go中的几个数据结构名称不会自动重命名过去,需要手动重命名。

也可以或者直接删除文件重新创建,但这样就要重新补全逻辑!

goctl rpc protoc user.proto --go_out=. --go-grpc_out=. --zrpc_out=. --style=goZero --verbose 

项目在internal/logic/目录下生成了createLogic.goqueryByIdLogic.go两个新文件,需要完善逻辑。但首先应该先配置一个gorm数据库连接。

配置gorm

下载软件包

运行如下代码,文章使用mysql数据库作为示例,因此还要安装mysql驱动

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

添加连接配置

打开etc/user.yaml并添加数据库配置

# ...
MySQL:  
    Host: 127.0.0.1  
    Port: 6033  
    User: microservice  
    Password: password  
    Database: microservice_user  
    TablePrefix: ""

这里的配置也可以直接给出一个DSN(Data Source Name),形如user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local

具体拼接方式见github.com/go-sql-driv…

扩展配置文件类型

internal/config/config.go定义了配置文件结构体的数据结构,需要扩展刚刚新增的MySQL数据部分:

package config  
  
import "github.com/zeromicro/go-zero/zrpc"  
  
type Config struct {  
    zrpc.RpcServerConf  
    MySQL struct {  
        Host string  
        Port int  
        User string  
        Password string  
        Database string  
        TablePrefix string  
    }  
}

创建数据库和用户

创建数据库和赋予用户权限不是本文的重点,这里就不详细介绍了,只贴一下SQL语句:

CREATE DATABASE `microservice_user` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_unicode_ci';

CREATE USER `microservice`@`%` IDENTIFIED WITH mysql_native_password BY 'password';

GRANT Alter, Alter Routine, Create, Create Routine, Create Temporary Tables, Create View, Delete, Drop, Event, Execute, Index, Insert, Lock Tables, References, Select, Show View, Trigger, Update ON `microservice\_user`.* TO `microservice`@`%`;

编写数据模型

这里我创建internal/model/models.go文件,并为user定义数据库模型

package model  
  
import "time"  
  
type User struct {  
    UserId int64 `gorm:"not null;primarykey;autoIncrement"`
    Username string `gorm:"type:varchar(24);not null;uniqueIndex"`  
    Password []byte `gorm:"type:VARBINARY(60);not null"`  
    CreatedAt time.Time `gorm:"not null"`  
    UpdatedAt time.Time `gorm:"not null"`  
}

这里嵌入了gorm.Model这个结构体,可以提供id、时间戳等gorm自动维护的字段,详细可见gorm.io/zh_CN/docs/…

集成数据库连接

阅读user.go会发现,项目启动时先尝试加载配置文件,然后传给NewServiceContext函数使用。ServiceContext是共享的上下文,适合设置一些全局可用的资源,所以应该在初始化函数中配置数据库。

打开internal/svc/serviceContext.go文件,连接的过程其实和gorm官网给出的步骤无异。这里做了几个关键步骤:

  • ServiceContext结构体添加了数据库指针项
  • 写了一个getDSN(c *config.Config)函数用来拼接DSN字符串
  • 连接数据库并迁移模型,遇到错误就退出
  • 将连接得到的db指针赋值给结构体的DB项
package svc  
  
import (  
    "fmt"  
    "gorm.io/driver/mysql"  
    "gorm.io/gorm"  
    "gorm.io/gorm/schema"  
    "user/internal/config"  
    "user/internal/model"  
)  
  
type ServiceContext struct {  
    Config config.Config  
    DB *gorm.DB  
}  
  
func NewServiceContext(c config.Config) *ServiceContext {  
    db, err := gorm.Open(mysql.Open(getDSN(&c)), &gorm.Config{  
        NamingStrategy: schema.NamingStrategy{  
            TablePrefix: c.MySQL.TablePrefix, // 表明前缀,可不设置  
            SingularTable: true, // 使用单数表名,即不会在表名后添加复数s  
        },  
    })  
    if err != nil {  
        panic(err)  
    }  
    err = db.AutoMigrate(&model.User{})  
    if err != nil {  
        panic(err)  
    }  

    return &ServiceContext{  
        Config: c,  
        DB: db,  
    }  
}  
  
func getDSN(c *config.Config) string {  
    return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local",  
        c.MySQL.User,  
        c.MySQL.Password,  
        c.MySQL.Host,  
        c.MySQL.Port,  
        c.MySQL.Database,  
    )  
}

实现业务逻辑

接下来就可以开始编写具体的业务逻辑

完善Create接口

这里会使用到bcrypt库用户密码的加盐和哈希提取,并且使用了grpc的status库来返回错误,库文档链接如下:

bcrypt pkg.go.dev/golang.org/… status pkg.go.dev/google.gola…

func (l *CreateLogic) Create(in *user.CreateRequest) (*user.CreateResponse, error) {  
    username := in.Username  
    password := in.Password  

    var count int64  
    err := l.svcCtx.DB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error  
    if err != nil {  
        return nil, status.Error(50000, err.Error())  
    }  

    if count > 0 {  
        return nil, status.Error(42201, "user already exist")  
    }  

    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)  
    if err != nil {  
        return nil, status.Error(50000, err.Error())  
    }  

    newUser := &model.User{  
        Username: username,  
        Password: hashedPassword,  
    }  

    err = l.svcCtx.DB.Create(newUser).Error  
    if err != nil {  
        return nil, status.Error(50000, err.Error())  
    }  

    return &user.CreateResponse{  
        UserId: newUser.UserId,  
    }, nil  
}

完善QuertById接口

这个接口的逻辑比较简单,就是一个数据库查询。

如果查询记录不存在的话,gorm的First链式调用是会报错的,错误是gorm.ErrRecordNotFound

func (l *QueryByIdLogic) QueryById(in *user.QueryByIdRequest) (*user.QueryResponse, error) {  
    userId := in.UserId  

    userRecord := model.User{}  
    err := l.svcCtx.DB.Where("user_id = ?", userId).First(&userRecord).Error  
    if err != nil {  
        if err == gorm.ErrRecordNotFound {  
            return nil, status.Error(40401, "user not found")  
        } else {  
            return nil, status.Error(50000, err.Error())  
        }  
    }  

    return &user.QueryResponse{  
        UserId: userRecord.UserId,  
        Username: userRecord.Username,  
        Password: userRecord.Password,  
        CreatedAt: userRecord.CreatedAt.Unix(),  
        UpdatedAt: userRecord.UpdatedAt.Unix(),  
    }, nil  
}

注意事项

这里为了简化演示流程,没有做字段检查和完善的错误处理。

因为使用的是v3版本的protobuf,已经不支持设置required字段,默认所有字段都是optional的,所以当客户端调用rpc接口传来不完整的请求数据时,会给结构体中缺失的字段设置一个类型默认值,比如空字符串

因此,数据结构完整性检查无需再判断nil了,而是结合正则表达式做具体内容判断。如果信任调用方的话可以不加,否则应该补全检查逻辑!

尝试调用接口

再次运行go run main.go启动服务,待服务启动后再次用grpcurl测试。

注意:这里我在linux上进行请求,因为windows的shell需要对引号、空格等进行大量转义,非常麻烦。

grpcurl -d '{"username":"rainchen","password":"password"}' -plaintext 192.168.0.22:8080 user.User.Create

请求成功,返回创建的用户id

{
    "userId": "1"
}
grpcurl -d '{"user_id":"1"}' -plaintext 192.168.0.22:8080 user.User.QueryById

请求成功,返回创建的用户信息

{
    "userId": "1",
    "username": "rainchen",
    "password": "JDJhJDEwJDdoTnBNcFZ0WndoVWdvVE5MTFlhLk84WGd2NXdoYUl1WFdZaDNNeXdKc3VkcDhZdG1wUS5p",
    "createdAt": "1693128219",
    "updatedAt": "1693128219"
}

自此,rpc接口测试成功。这里再附一个缺少请求字段且没做字段检测的结果展示,以作警醒

grpcurl -d '{}' -plaintext 192.168.0.22:8080 user.User.Create
// 没有传字段也正常插入了数据库,因为string默认是空字符串不是nil,所以过了not null检查
{
   "userId": "2"
}

grpcurl -d '{"user_id":"2"}' -plaintext 192.168.0.22:8080 user.User.FetchUser
// 再查询出来的时候不会显示空串,所以username不见了
{
    "userId": "2",
    "password": "JDJhJDEwJG8xTjZ0dmk4TU5YTENhMXZaZGdlcU9xS3A3OC5Ib0QwWnAyTTV3eEF0Z2FGU1RvYlBxWFhx",
    "createdAt": "1693128426",
    "updatedAt": "1693128426"
}

因此使用v3协议编写rpc服务的时候,尽管对调用方有一定的信任,而且不会出现nil,但还是要做好字段检查!