gtodolist 项目说明和环境准备 | 青训营

97 阅读6分钟

项目说明

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 写的是真难受