项目说明
gtodolist 是一个带有前端页面的简易备忘录项目, 使用 gozero 框架开发, 帮助同学快速熟悉和入门 gozero 框架, 项目地址在这里
项目前端来自于 github todolist, 原作者使用 gin+gorm 完成后端开发, 笔者将使用 gozero 进行重构
环境准备
假设你已经安装好了 gozero 所需的所有环境, 如果没有, 请参考 快速入门 gozero 框架
项目结构
新建文件夹 gtodolist 作为项目目录, 使用 gomod 进行管理并添加到 gowork 中
此项目拟作为微服务项目并采用 git 进行版本管理, 所以目前创建目录如下:
D:\GOPROJECTS\SRC\GTODOLIST
│ go.mod # mod依赖管理
│ README.md # 自述文件
│
├─app # 项目主目录
│ ├─task # task 模块
│ │ ├─cmd # 服务
│ │ │ ├─api
│ │ │ └─rpc
│ │ ├─model # 数据库
│ │ └─template # 模板文件
│ └─user # user 模块
│ ├─cmd
│ │ ├─api
│ │ └─rpc
│ ├─model
│ └─template
└─common # 通用的工具类等
创建数据库
user 表:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted_at` datetime(3) NULL DEFAULT NULL COMMENT '删除时间',
`username` varchar(191) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户名',
`password_digest` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '密码',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `username`(`username`) USING BTREE,
INDEX `idx_users_deleted_at`(`deleted_at`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
task 表:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `task`;
CREATE TABLE `task` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted_at` datetime(3) NULL DEFAULT NULL COMMENT '删除时间',
`uid` bigint(20) UNSIGNED NOT NULL COMMENT '用户id',
`title` varchar(191) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '任务标题',
`status` tinyint(20) NOT NULL DEFAULT 0 COMMENT '任务状态 0代办 1已完成',
`content` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '任务内容',
`start_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '任务开始时间',
`end_time` timestamp NULL DEFAULT NULL COMMENT '任务结束时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_tasks_deleted_at`(`deleted_at`) USING BTREE,
INDEX `idx_tasks_title`(`title`) USING BTREE,
INDEX `fk_tasks_user`(`uid`) USING BTREE,
CONSTRAINT `fk_tasks_user` FOREIGN KEY (`uid`) REFERENCES `user` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
准备工作结束, 下一部分将介绍 user 模块的编写
本章节内容均在
user目录下完成
准备模块框架
生成 api 代码
在 cmd/api 目录下新建一个 desc 目录编写 api 文件
syntax = "v1"
info(
title: "User API"
desc: "API for user"
author: "GuoChenxu"
email: "2269409349@qq.com"
version: "1.0"
)
// register
type (
RegisterReq {
Username string `json:"user_name"`
Password string `json:"password"`
}
RegisterResp {
Status int `json:"status"`
Data string `json:"data"`
Message string `json:"msg"`
Error string `json:"error"`
}
)
// login
type (
User {
Id int64 `json:"id"`
Username string `json:"user_name"`
CreateAt int64 `json:"create_at"`
}
Data {
User User `json:"user"`
Token string `json:"token"`
}
LoginReq {
Username string `json:"user_name"`
Password string `json:"password"`
}
LoginResp {
Status int `json:"status"`
Data Data `json:"data"`
Message string `json:"msg"`
Error string `json:"error"`
}
)
// api
@server(
prefix: api/v1/user
)
service user {
@doc "register"
@handler register
post /register (RegisterReq) returns (RegisterResp)
@doc "login"
@handler login
post /login (LoginReq) returns (LoginResp)
}
然后在 api 目录下生成代码文件
goctl api go --api .\desc\user.api --dir .\ --style=go_zero
生成 rpc 代码
同样地, 在 cmd/rpc 目录下新建 desc 目录, 编写 proto 文件, 这里就不分开写了
syntax = "proto3";
package pb;
option go_package = "./pb";
// 定义消息类型
message RegisterReq {
string Username = 1;
string Password = 2;
}
message RegisterResp {
int32 Status = 1;
string Data = 2;
string Message = 3;
string Error = 4;
}
message LoginReq {
string Username = 1;
string Password = 2;
}
message User {
int64 Id = 1;
string Username = 2;
int64 create_at = 3;
}
message Data {
User User = 1;
string Token = 2;
}
message LoginResp {
int32 Status = 1;
Data Data = 2;
string Message = 3;
string Error = 4;
}
message GenerateTokenReq {
int64 userId = 1;
}
message GenerateTokenResp {
string accessToken = 1;
int64 accessExpire = 2;
int64 refreshAfter = 3;
}
// 定义服务
service userrpc {
rpc Register(RegisterReq) returns(RegisterResp);
rpc Login(LoginReq) returns(LoginResp);
rpc GenerateToken(GenerateTokenReq) returns(GenerateTokenResp);
}
然后在 rpc 目录下生成代码
goctl rpc protoc .\desc\user.proto --go_out=.\ --go-grpc_out=.\ --zrpc_out=.\ --style=go_zero
生成 model 代码
在 model 目录下我们根据数据库中的 user 表生成代码文件, 这里我们使用一个 gorm 和 gozero 整合的库, 同时我们也需要使用他的模板文件来生成代码
# --home 指定模板文件在本地的位置 (远程使用 --remote), 不填是使用 gozero 默认的模板
# --cache 是否启用缓存, 不填默认不启用
goctl model mysql datasource -url="root:101325@tcp(127.0.0.1:3306)/gtodolist" -table="user" --dir="./" --home="../templat
e/gorm-gozero/1.4.2" --style=go_zero --cache=true
目前 model 目录如下
D:\GOPROJECTS\SRC\GTODOLIST\APP\USER\MODEL
user.sql
user_model.go
user_model_gen.go
vars.go
到目前位置我们 user 模块的代码就全部生成好了
修改配置文件
接下来我们要修改项目的配置文件
api
修改 etc/user.yaml 配置文件
Name: user
Host: 0.0.0.0
Port: 22301
Log:
Encoding: plain
UserRpcConfig:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc
# jwt验证
JwtAuth:
AccessSecret: gtodolist
AccessExpire: 31536000
internal/config/config.go
type Config struct {
rest.RestConf
JwtAuth struct {
AccessSecret string
AccessExpire int64
}
UserRpcConfig zrpc.RpcClientConf
}
internal/svc/service_context.go
type ServiceContext struct {
Config config.Config
LoginMiddleware rest.Middleware
UserRpcClient userrpc.Userrpc
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
LoginMiddleware: middleware.NewLoginMiddleware().Handle,
UserRpcClient: userrpc.NewUserrpc(zrpc.MustNewClient(c.UserRpcConfig)),
}
}
rpc
同样是 etc/user.yaml 文件
Name: user.rpc
ListenOn: 0.0.0.0:22351
Etcd:
Hosts:
- 0.0.0.0:2379
Key: user.rpc
# 日志
Log:
Encoding: plain
# jwt验证
JwtAuth:
AccessSecret: gtodolist
AccessExpire: 31536000
# mysql
Mysql:
Path: 127.0.0.1
Port: 3306
Dbname: gtodolist
Username: root
Password: "101325"
MaxIdleConns: 10
MaxOpenConns: 10
Config: parseTime=True&loc=Local
Cache:
- Host: 127.0.0.1:6379
Pass: "101325"
Redis:
Host: 127.0.0.1:6379
Pass: "101325"
Type: node
Key: user.rpc
internal/config/config.go
type Config struct {
zrpc.RpcServerConf
JwtAuth struct {
AccessSecret string
AccessExpire int64
}
Mysql gormc.Mysql
Cache cache.CacheConf
}
internal/svc/service_context.go
type ServiceContext struct {
Config config.Config
UserModel model.UserModel
}
func NewServiceContext(c config.Config) *ServiceContext {
db, err := gormc.ConnectMysql(c.Mysql)
if err != nil {
log.Fatal(err)
}
return &ServiceContext{
Config: c,
UserModel: model.NewUserModel(db, c.Cache),
}
}
编写核心逻辑
api
因为主要操作数据库的部分都在 rpc 中完成, 所以 api 中的逻辑比较简单, 主要就是调用 rpc 中的方法
register
internal\logic\register_logic.go
func (l *RegisterLogic) Register(req *types.RegisterReq) (resp *types.RegisterResp, err error) {
// 向 rpc 发送请求
registerResp, err := l.svcCtx.UserRpcClient.Register(l.ctx, &pb.RegisterReq{
Username: req.Username,
Password: req.Password,
})
_ = copier.Copy(resp, registerResp)
return resp, err
}
login
internal\logic\login_logic.go
func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
loginResp, err := l.svcCtx.UserRpcClient.Login(l.ctx, &pb.LoginReq{
Username: req.Username,
Password: req.Password,
})
if err != nil {
return &types.LoginResp{
Status: int(loginResp.Status),
Message: loginResp.Message,
Error: err.Error(),
}, err
}
_ = copier.Copy(resp, loginResp)
return resp, err
}
rpc
register
internal\logic\register_logic
执行流程: 检查是否存在同名用户 (捕获错误) -> 创建用户 (捕获错误) -> 返回结果
func (l *RegisterLogic) Register(in *pb.RegisterReq) (*pb.RegisterResp, error) {
// 根据用户名查询用户是否存在
user, err := l.svcCtx.UserModel.FindOneByUsername(l.ctx, sql.NullString{
String: in.Username,
Valid: true,
})
// 查询出错
if err != nil && err != model.ErrNotFound {
return &pb.RegisterResp{
Status: vo.ErrRequestParamError.GetErrCode(),
Data: "",
Message: vo.ErrRequestParamError.GetErrMsg(),
Error: err.Error(),
}, err
}
// 用户名已存在
if user != nil {
return &pb.RegisterResp{
Status: vo.ErrUserAlreadyRegisterError.GetErrCode(),
Data: "",
Message: vo.ErrUserAlreadyRegisterError.GetErrMsg(),
Error: "",
}, err
}
// 添加用户
username := sql.NullString{
String: in.Username,
Valid: true,
}
bp, _ := tool.BcryptByString(in.Password)
passwordDigest := sql.NullString{
String: bp,
Valid: true,
}
user = &model.User{
Username: username,
PasswordDigest: passwordDigest,
}
err = l.svcCtx.UserModel.Insert(l.ctx, nil, user)
// 注册失败
if err != nil {
return &pb.RegisterResp{
Status: vo.ErrDBerror.GetErrCode(),
Data: "",
Message: vo.ErrDBerror.GetErrMsg(),
Error: err.Error(),
}, err
}
return &pb.RegisterResp{
Status: vo.OK,
Data: vo.SUCCESS,
Message: vo.SUCCESS,
Error: "",
}, nil
}
token
internal\logic\generate_token_logic
登录时我们需要返回一个 token, 所以我们在编写登录代码前先写一个生成 token 的服务
这个是通用的函数, 直接复制即可
func (l *GenerateTokenLogic) GenerateToken(in *pb.GenerateTokenReq) (*pb.GenerateTokenResp, error) {
now := time.Now().Unix()
accessExpire := l.svcCtx.Config.JwtAuth.AccessExpire
accessToken, err := l.getJwtToken(l.svcCtx.Config.JwtAuth.AccessSecret, now, accessExpire, in.UserId)
// 生成 token 失败
if err != nil {
return nil, errors.Wrapf(vo.ErrGenerateTokenError, "getJwtToken err userId:%d , err:%v", in.UserId, err)
}
return &pb.GenerateTokenResp{
AccessToken: accessToken,
AccessExpire: now + accessExpire,
RefreshAfter: now + accessExpire/2,
}, nil
}
func (l *GenerateTokenLogic) getJwtToken(secretKey string, iat, seconds, userId int64) (string, error) {
claims := make(jwt.MapClaims)
claims["exp"] = iat + seconds
claims["iat"] = iat
claims[ctxdata.CtxKeyJwtUserId] = userId
token := jwt.New(jwt.SigningMethodHS256)
token.Claims = claims
return token.SignedString([]byte(secretKey))
}
login
internal\logic\login_logic
执行流程: 查询用户看是否存在 (捕获错误) -> 匹配密码 -> 生成 token -> 返回结果
func (l *LoginLogic) Login(in *pb.LoginReq) (*pb.LoginResp, error) {
// 查询用户是否存在且密码正确
user, err := l.svcCtx.UserModel.FindOneByUsername(l.ctx, sql.NullString{
String: in.Username,
Valid: true,
})
if err != nil && err != model.ErrNotFound {
return &pb.LoginResp{
Status: vo.ErrRequestParamError.GetErrCode(),
Message: vo.ErrRequestParamError.GetErrMsg(),
Error: err.Error(),
}, err
}
if user == nil {
return &pb.LoginResp{
Status: vo.ErrUserNoExistsError.GetErrCode(),
Message: vo.ErrUserNoExistsError.GetErrMsg(),
Error: vo.ErrUserNoExistsError.GetErrMsg(),
}, errors.Wrap(vo.ErrUserNoExistsError, "用户不存在")
}
if !tool.CheckPasswordHash(in.Password, user.PasswordDigest.String) {
return &pb.LoginResp{
Status: vo.ErrUsernamePwdError.GetErrCode(),
Message: vo.ErrUsernamePwdError.GetErrMsg(),
Error: vo.ErrUsernamePwdError.GetErrMsg(),
}, errors.Wrap(vo.ErrUsernamePwdError, "密码匹配出错")
}
// 生成 token
genToken := NewGenerateTokenLogic(l.ctx, l.svcCtx)
tokenResp, err := genToken.GenerateToken(&userrpc.GenerateTokenReq{
UserId: user.Id,
})
if err != nil {
return &pb.LoginResp{
Status: vo.ErrGenerateTokenError.GetErrCode(),
Message: vo.ErrGenerateTokenError.GetErrMsg(),
Error: vo.ErrGenerateTokenError.GetErrMsg(),
}, errors.Wrap(vo.ErrGenerateTokenError, "生成 token 失败")
}
return &pb.LoginResp{
Status: vo.OK,
Data: &pb.Data{
User: &pb.User{
Id: user.Id,
Username: user.Username.String,
CreateAt: user.CreatedAt.Unix(),
},
Token: tokenResp.AccessToken,
},
Message: vo.SUCCESS,
Error: "",
}, nil
}
用户的注册登录到这就基本结束了, 还剩下测试看有没有 bug 不得不说, 这前端的 api 写的是真难受