项目地址:github
概述
这一节我们将实现用户的注册登录,以及关注的后台功能
定义用户模型
在model目录下新建user.go文件。我们将和user相关的结构体都定义在里面。
package model
import "time"
type Gender int
const (
Man Gender = iota + 1
Woman
Unknown
)
type UserState int
const (
Unsigned UserState = iota + 1
Normal
Forbidden
Freeze
)
type User struct {
Id uint64 `graphql:"id"`
Username string `graphql:"username"`
Email string `graphql:"email"`
Password string `graphql:"-"`
Avatar string `graphql:"avatar"`
Gender Gender `graphql:"gender"`
Introduce *string `graphql:"introduce"`
State UserState `graphql:"state"`
Root bool `graphql:"root"`
CreatedAt time.Time `graphql:"createdAt"`
UpdatedAt time.Time `graphql:"updatedAt"`
DeletedAt *time.Time `graphql:"deletedAt"`
Count UserCount `graphql:"-"`
}
type UserCount struct {
Uid uint64 `graphql:"-"`
FansNum int `graphql:"fansNum"`
FollowNum int `graphql:"followNum"`
ArticleNum int `graphql:"articleNum"`
Words int `graphql:"words"`
LikeNum int `graphql:"likeNum"`
CreatedAt time.Time `graphql:"-"`
UpdatedAt time.Time `graphql:"-"`
DeletedAt time.Time `graphql:"-"`
}
type UserFollow struct {
Id int64 `graphql:"-"`
Uid int64 `graphql:"-"`
Fuid int64 `graphql:"-"`
CreatedAt time.Time `graphql:"createdAt"`
UpdatedAt time.Time `graphql:"updatedAt"`
DeletedAt *time.Time `graphql:"deletedAt"`
}
可以看到,我们使用了tag:graphql来标识结构体中字段在GraphQL中的命名,其中"-"表示忽略该字段。
在User的定义中,Introduce和DeletedAt使用了指针,这是因为在graphql库中,对于字段可以为空或者非空的判定,正是根据是否是指针类型来判断的。
graphql库中的GraphQL类型,大多与Go中类型保持一致,比如int,string这种类型,因为在Go中不可能为空,所以映射到GraphQL中时也是非空类型。若需要定义一个空的基本类型,需要使用指针。
将用户模型映射到GraphQL中
我们刚刚定义了关于用户的三个结构体,那么如何将这些模型在GraphQL中体现呢?
首先,我们需要在handler目录下,新建graphql.go文件,用来整合处理所有的模型注册事件。
package resolve
func Register(){
}
现在graphql中只有一个空的Register函数。我们需要为它添加内容。在同级目录下新建user.go文件。
package resolve
import "github.com/shyptr/graphql/schemabuilder"
func registerUser(schema *schemabuilder.Schema) {
}
schema正是graphql中用于将我们定义好的数据类型映射到GraphQL中的媒介。
我们先来分析一下,对于用户数据而言,如果我们要从前端界面获取用户数据,需要哪些数据。如下列了在简书项目中,我们要使用到的数据。
- 用户基本信息,Id,用户名,头像,邮箱等
- 用户计数,包括文章数,粉丝数等
- 用户关系网,譬如粉丝列表,关注列表
- 用户发表的内容,文章,评论等
由于我们现在只涉及用户,所有文章评论等暂不考虑。那么接下来就该注册这些数据到GraphQL中了。
修改handler.user.go。
func registerUser(schema *schemabuilder.Schema) {
// 枚举类型映射
schema.Enum("Gender", model.Gender(0), map[string]model.Gender{
"Man": model.Man,
"Woman": model.Woman,
"Unknown": model.Unknown,
})
schema.Enum("UserState", model.UserState(0), map[string]model.UserState{
"Unsigned": model.Unsigned,
"Forbidden": model.Forbidden,
"Freeze": model.Freeze,
})
// 将user结构体映射到graphql
user := schema.Object("User", model.User{})
// 粉丝数,关注数,文章数,字数,被点赞数
user.FieldFunc("FansNum", func(u model.User) int { return u.Count.FansNum })
user.FieldFunc("FollowNum", func(u model.User) int { return u.Count.FollowNum })
user.FieldFunc("ArticleNum", func(u model.User) int { return u.Count.ArticleNum })
user.FieldFunc("Words", func(u model.User) int { return u.Count.Words })
user.FieldFunc("LikeNum", func(u model.User) int { return u.Count.LikeNum })
// 粉丝列表
user.FieldFunc("Fans", func() []model.User { return nil })
// 关注列表
user.FieldFunc("Followed", func() []model.User { return nil })
query := schema.Query()
// 获取用户信息
query.FieldFunc("User", func() model.User { return model.User{} })
}
Enum将我们定义的枚举类型,转换成字符类型的枚举值列表在GraphQL中展示。
在graphql中,结构体被定义为Object,对于tag:graphql不为"-"的字段,graphql会自动处理,无需单独定义。
而像粉丝数,粉丝列表这样的字段,则需要调用object的FieldFunc方法进行注册。该方法第一个参数是这个字段的名称,第二个参数则是这个字段的解析函数。
这里粉丝列表和关注列表,我们没有准备在这里实现解析函数的逻辑。handler中应该只是像路由一样,作为转发,对于复杂逻辑,应该在resolve中单独定义。
对于用户数据的获取,这里就定义完了。剩下的就是对用户的动作的定义。
- 注册
- 登录
- 关注
- 取消关注
这就是我们这一节的大头了。仍然修改handler.user.go文件,添加内容。
mutation := schema.Mutation()
// 注册
mutation.FieldFunc("SignUp", func() model.User { return model.User{} })
// 登录
mutation.FieldFunc("SingIn", func() model.User { return model.User{} })
// 关注
mutation.FieldFunc("Follow", func() {})
// 取消关注
mutation.FieldFunc("UnFollow", func() {})
修改handler.graphql.go文件。
func Register(){
schema:=schemabuilder.NewSchema()
registerUser(schema)
}
编写解析函数
现在我们终于要和数据库打交道了。
我们在前面定义了一个查询单个用户的query字段,用户的信息在定义中,包括基本信息,计数,关注与被关注情况。
我们修改model.user.go文件,添加如下内容。
func GetUser(tx *sqlog.DB, id uint64, username, email string) (User, error) {
rows, err := PSql.Select("id,username,email,password,avatar,gender,introduce,state,root,created_at,updated_at,deleted_at").
From(`"user"`).
Where("deleted_at is null").
WhereExpr(
sqlex.IF{id != 0, sqlex.Eq{"id": id}},
).
WhereExpr(
sqlex.Or{
sqlex.IF{username != "", sqlex.Eq{"username": username}},
sqlex.IF{email != "", sqlex.Eq{"email": email}},
},
).
RunWith(tx).Query()
if err != nil {
return User{}, err
}
var user User
defer rows.Close()
if rows.Next() {
err := rows.Scan(&user.Id, &user.Username, &user.Email, &user.Password, &user.Avatar, &user.Gender, &user.Introduce, &user.State,
&user.Root, &user.CreatedAt, &user.UpdatedAt, &user.DeletedAt)
if err != nil {
return user, err
}
}
return user, nil
}
func GetUserCount(tx *sqlog.DB, id uint64) (UserCount, error) {
rows, err := PSql.Select("fans_num,follow_num,article_num,words,like_num").
From("user_count").
Where("uid=$1", id).
Where("deleted_at is null").
RunWith(tx).Query()
if err != nil {
return UserCount{}, err
}
var c UserCount
defer rows.Close()
if rows.Next() {
err := rows.Scan(&c.FansNum, &c.FollowNum, &c.ArticleNum, &c.Words, &c.LikeNum)
if err != nil {
return c, err
}
}
return c, nil
}
func GetUserFollower(tx *sqlog.DB, id uint64) ([]uint64, error) {
rows, err := PSql.Select("fuid").
From("user_follow").
Where("uid=$1", id).
Where("deleted_at is null").
RunWith(tx).Query()
if err != nil {
return nil, err
}
var fs []uint64
defer rows.Close()
for rows.Next() {
var f uint64
err := rows.Scan(&f)
if err != nil {
return nil, err
}
fs = append(fs, f)
}
return fs, nil
}
func GetFollowUser(tx *sqlog.DB, id uint64) ([]uint64, error) {
rows, err := PSql.Select("uid").
From("user_follow").
Where("fuid=$1", id).
Where("deleted_at is null").
RunWith(tx).Query()
if err != nil {
return nil, err
}
var fs []uint64
defer rows.Close()
for rows.Next() {
var f uint64
err := rows.Scan(&f)
if err != nil {
return nil, err
}
fs = append(fs, f)
}
return fs, nil
}
在resolve下新建user.go文件。
package resolve
import (
"context"
"fmt"
"github.com/shyptr/jianshu/model"
"github.com/shyptr/jianshu/util"
)
type userResolver struct{}
var UserResolver userResolver
type idArgs struct {
Id int64 `graphql:"id"`
}
// 根据用户ID查询用户信息
func (u userResolver) User(ctx context.Context, args idArgs) (model.User, error) {
logger := util.GetLogger()
defer util.PutLogger(logger)
user, err := model.GetUser(args.Id)
if err != nil {
logger.Error().Caller().Err(err).Send()
return model.User{}, fmt.Errorf("查询用户信息失败")
}
count, err := model.GetUserCount(args.Id)
if err != nil {
logger.Error().Caller().Err(err).Send()
return model.User{}, fmt.Errorf("查询用户信息失败")
}
user.Count = count
return user, nil
}
// 粉丝列表
func (u userResolver) Followers(ctx context.Context, user model.User) ([]model.User, error) {
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
ids, err := model.GetUserFollower(tx, user.Id)
if err != nil {
logger.Error().Caller().Err(err).Send()
return nil, fmt.Errorf("查询用户信息失败")
}
var users []model.User
for _, id := range ids {
user, err := u.User(ctx, IdArgs{id})
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
// 关注列表
func (u userResolver) Follows(ctx context.Context, user model.User) ([]model.User, error) {
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
ids, err := model.GetFollowUser(tx, user.Id)
if err != nil {
logger.Error().Caller().Err(err).Send()
return nil, fmt.Errorf("查询用户信息失败")
}
var users []model.User
for _, id := range ids {
user, err := u.User(ctx, IdArgs{id})
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
关于查询的逻辑,都很简单,没有可以多说的。重点还是接下来的注册和登录。
唯一需要注意的是,关注列表和粉丝列表,由于在GraphQL中是属于User的字段,所以id的参数并不需要从客户端传入,而是从source获取。
在这里source就是我们通过query查询到的User结构体了,所以在函数参数处,我们没有使用args,而是用了user model.Use。
修改model.user.go文件,添加内容如下。
type UserArg struct {
Username string `graphql:"username" validate:"min=6,max=16"`
Email string `graphql:"email" validate:"email"`
Password string `graphql:"password" validate:"min=8"`
Avatar string `graphql:"-"`
}
func InsertUser(tx *sqlog.DB, arg UserArg) (uint64, error) {
id, err := idfetcher.NextID()
if err != nil {
return id, err
}
result, err := PSql.Insert(`"user"`).
Columns("id,username,email,password,avatar").
Values(id, arg.Username, arg.Email, arg.Password,arg.Avatar).
RunWith(tx).Exec()
if err != nil {
return id, err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return id, fmt.Errorf("保存用户信息失败")
}
return id, nil
}
func InsertUserCount(tx *sqlog.DB, id uint64) error {
result, err := PSql.Insert("user_count").Columns("uid").Values(id).RunWith(tx).Exec()
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("保存用户信息失败")
}
return nil
}
func InsertUserFollow(tx *sqlog.DB, uid uint64, fuid uint64) error {
id, err := idfetcher.NextID()
if err != nil {
return err
}
result, err := PSql.Insert("user_follow").Columns("id,uid,fuid").Values(id, uid, fuid).RunWith(tx).Exec()
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("关注失败")
}
return nil
}
func DeleteUserFollow(tx *sqlog.DB, id uint64, fuid uint64) error {
result, err := PSql.Update("user_follow").
Set("deleted_at", time.Now()).
Where(sqlex.Eq{"uid": id, "fuid": fuid}).
RunWith(tx).Exec()
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("取消关注失败")
}
return nil
}
我们这里先写注册登录的逻辑。
修改resolve/user.go文件,新增内容。
// 注册
func (u userResolver) SingUp(ctx context.Context, args model.UserArg) (user model.User, err error) {
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
err = u.ValidUsername(ctx, usernameArg{Username: args.Username})
if err != nil {
return model.User{}, err
}
err = u.ValidEmail(ctx, emailArg{Email: args.Email})
if err != nil {
return model.User{}, err
}
// 密码加密
password, err := bcrypt.GenerateFromPassword([]byte(args.Password), 10)
if err != nil {
logger.Error().Caller().Err(err).Send()
return model.User{}, errors.New("注册失败")
}
args.Password = string(password)
id, err := model.InsertUser(tx, args)
if err != nil {
logger.Error().Caller().Err(err).Send()
return model.User{}, errors.New("注册失败")
}
err = model.InsertUserCount(tx, id)
if err != nil {
logger.Error().Caller().Err(err).Send()
return model.User{}, errors.New("注册失败")
}
// TODO:邮箱验证
user, _ = model.GetUser(tx, id, "", "")
return user, nil
}
func (u userResolver) SignIn(ctx context.Context, args struct {
Username string `graphql:"username"` // 邮箱或者用户名
Password string `graphql:"password"`
RememberMe bool `graphql:"rememberme"`
}) (user model.User, err error) {
// 验证账号密码
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
user, err = model.GetUser(tx, 0, args.Username, args.Username)
if err != nil {
logger.Error().Caller().AnErr("登录失败", err).Send()
return model.User{}, errors.New("登录失败")
}
if user.Id == 0 {
return model.User{}, errors.New("用户不存在!")
}
// 验证密码
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(args.Password))
if err != nil {
return model.User{}, err
}
// 生成token,并设置cookie
var age int
if args.RememberMe {
age = 7 * 24
}
token, err := util.GeneraToken(user.Id, age)
if err != nil {
logger.Error().Caller().AnErr("生成token失败", err).Send()
return model.User{}, errors.New("登录失败")
}
c := ctx.(*graphql.Context)
http.SetCookie(c.Writer, &http.Cookie{
Name: "me",
Value: token,
Path: "/",
Expires: time.Now(),
MaxAge: int(time.Hour) * age,
})
return user, nil
}
在注册时,我们对用户名和邮箱进行了一次唯一性校验。同时也可以看到我们在model.UserArg上增加了validate的tag。
这是因为graphql默认引入了validator库,用于参数的校验。当然,validate的使用需要手动开启,默认情况下是不开启的。
注册时,简书项目使用了Go的扩展库golang.org/x/crypto下的bcrypt包。bcrypt包使用base64编码,实现了Provos和Mazières的bcrypt自适应散列算法。
该算法的具体内容可以看这篇论文《A Future-Adaptable Password Scheme》
。可以看到,我们在加密的时候传了一个10到GenerateFromPassword函数中。这里可以理解为表示该密码被破译需要花费的代价。
这个cost值越高,加密就越复杂,越难以破解。当然相应的,加密的过程耗费的时间也越长,使用时应权衡好具体数值。
登录最主要的就是session的管理。关于token,session,cookie的区别,这里不多赘述,网上已经有很多详解了。在这里,我们使用了jwt-go库。
将用户的Id作为session信息,通过加密得到token,并将token存入客户端的cookie中。即我们的session信息,是通过cookie存储的。
最后是关注与取消关注的解析函数,修改resolve/user.go。
// 关注
func (u userResolver) Follow(ctx context.Context, args struct {
Id uint64 `graphql:"id"`
}) error {
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
userId := ctx.Value("userId").(uint64)
err := model.InsertUserFollow(tx, args.Id, userId)
if err != nil {
logger.Error().Caller().AnErr("关注失败", err).Send()
return errors.New("关注失败")
}
// TODO: 发送通知
return nil
}
// 取消关注
func (u userResolver) CancelFollow(ctx context.Context, args struct {
Id uint64 `graphql:"id"`
}) error {
logger := ctx.Value("logger").(zerolog.Logger)
tx := ctx.Value("tx").(*sqlog.DB)
userId := ctx.Value("userId").(uint64)
err := model.DeleteUserFollow(tx, args.Id, userId)
if err != nil {
logger.Error().Caller().AnErr("取消关注失败", err).Send()
return errors.New("取消关注失败")
}
return nil
}
将复杂解析函数注册到GraphQL对应字段
现在我们的业务逻辑都在解析函数中编写完成了。接下来就要将这些函数注册到对应的字段上去。
修改handler/user.go文件。
func registerUser(schema *schemabuilder.Schema) {
// 枚举类型映射
schema.Enum("Gender", model.Gender(0), map[string]model.Gender{
"Man": model.Man,
"Woman": model.Woman,
"Unknown": model.Unknown,
})
schema.Enum("UserState", model.UserState(0), map[string]model.UserState{
"Unsigned": model.Unsigned,
"Forbidden": model.Forbidden,
"Freeze": model.Freeze,
})
// 将user结构体映射到graphql
user := schema.Object("User", model.User{})
// 粉丝数,关注数,文章数,字数,被点赞数
user.FieldFunc("FansNum", func(u model.User) int { return u.Count.FansNum })
user.FieldFunc("FollowNum", func(u model.User) int { return u.Count.FollowNum })
user.FieldFunc("ArticleNum", func(u model.User) int { return u.Count.ArticleNum })
user.FieldFunc("Words", func(u model.User) int { return u.Count.Words })
user.FieldFunc("LikeNum", func(u model.User) int { return u.Count.LikeNum })
// 粉丝列表
user.FieldFunc("Fans", resolve.UserResolver.Followers)
// 关注列表
user.FieldFunc("Followed", resolve.UserResolver.Follows)
query := schema.Query()
// 获取用户信息
query.FieldFunc("User", resolve.UserResolver.User)
mutation := schema.Mutation()
// 注册
mutation.FieldFunc("SignUp", resolve.UserResolver.SingUp, middleware.BasicAuth(), middleware.LoginNeed())
// 登录
mutation.FieldFunc("SingIn", resolve.UserResolver.SignIn, middleware.BasicAuth(), middleware.NotLogin())
// 关注
mutation.FieldFunc("Follow", resolve.UserResolver.Follow, middleware.BasicAuth(), middleware.LoginNeed())
// 取消关注
mutation.FieldFunc("UnFollow", resolve.UserResolver.CancelFollow, middleware.BasicAuth(), middleware.LoginNeed())
}
启动GraphiQL
现在我们在main方法中调用handler的Register方法。
修改handler/graphql.go文件。
func Register(mux *http.ServeMux) {
builder := schemabuilder.NewSchema()
registerUser(builder)
schema, err := builder.Build()
if err != nil {
log.Fatalln(err)
}
introspection.AddIntrospectionToSchema(schema)
mux.Handle("/", graphql.GraphiQLHandler("/graphql"))
mux.Handle("/graphql", graphql.HTTPHandler(schema))
}
修改main函数。
handler.Register(mux)
命令启动项目。
go run cmd/jianshu/main.go
最终效果如下。
作者个人博客地址:https://unrotten.org
作者微信公众号:
有疑问加站长微信联系(非本文作者)