11

Go+GraphQL+React+Typescript搭建简书项目(四)——用户模块(后端)

 4 years ago
source link: https://studygolang.com/articles/28051
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

项目地址: 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

最终效果如下。

feiIRze.png!web

作者个人博客地址: https://unrotten.org

作者微信公众号:

fAVjiqU.png!web

欢迎关注我们的微信公众号,每天学习Go知识

FveQFjN.jpg!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK