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

云燕 · · 568 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

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

最终效果如下。

Graphiql


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

作者微信公众号:


有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

568 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传