前言
在青训营学习了微服务相关课程后,我打算自己动手尝试编写一个用户微服务,计划选用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提供了多种安装方法,因为我们本地已经搭建了go开发环境,因此可以直接使用go install命令安装
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
尝试请求
现在命令行使用grpcurl请求我们的微服务:
grpcurl -plaintext 127.0.0.1:8080 user.User/Ping
其中user和User分别对应user.proto中定义的package name和service name,而Ping是具体的方法。
你应该会得到如下的输出,代表服务正常工作。
定义更多接口
目前这个微服务只能实现最基础的存活探测功能。要给微服务添加更多功能,首先需要在proto文件中定义对应的接口。
proto文件现有的Request和Response数据结构是针对Ping方法的,这里先将其重命名成PingRequest和PingResponse。
本文中仅演示简单的用户创建和用户信息获取功能,不设置额外的鉴权和token发放,相关内容我会在后面的文章补全。因此,新设两个接口Create和QueryById
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.go和queryByIdLogic.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,但还是要做好字段检查!