项目地址:github

什么是middleware

在web服务中,通常我们都有实现拦截器的需求,或用于日志的记录,或用于事务的开启,或是权限的鉴定等。
在Go Web中,这种拦截器常常被称为middleware——中间件。

在原生的Go http服务中,middleware通常使用装饰模式实现,定义常常如下。

type WrapperHander func(ResponseWriter, *Request) func(ResponseWriter, *Request)

我们通过这样一层一层的装饰,来实现顺序的调用。

在Go中,常用的middleware多用于记录请求日志,recovery等。同样的,在graphql中,也提供了类似的实现。

全局middleware

顾名思义,全局的middleware,将会在每一次请求中都被调用。参考gin框架对此的实现,graphql也以类似的方式实现了middleware。

其实早在上一节main方法中,我们就已经使用了graphql提供的两个middleware。

graphql.Use(middleware.Logger(), middleware.Recovery())

使用graphql.Use函数进行注册。这里我们将自己实现这些middleware。

Logger

新建middleware目录,在目录下新建middleware.go文件。

package middleware

import (
    "github.com/shyptr/graphql"
    "github.com/shyptr/jianshu/util"
    "time"
)

func Logger() graphql.HandlerFunc {
    return func(c *graphql.Context) {
        logger := util.GetLogger()
        c.Set("logger", logger)
        start := time.Now()
        defer func() {
            reqMethod := c.Request.Method
            statusCode := c.Writer.Status()
            clientIP := c.ClientIP()
            operationName := c.Value("operationName")
            if operationName == "" {
                operationName = "query"
            }
            logger.Info().Int("status", statusCode).Str("method", reqMethod).TimeDiff("latencyTime", start, time.Now()).
                Str("ip", clientIP).Interface("operationName", operationName).Send()
            util.PutLogger(logger)
        }()
        c.Next()
    }
}

graphql的Context提供了设置key-value的方法。这样做有一个好处,就是下游函数设置的key-value,在上游函数中也可以读到。
在这里我们将初始化的logger放入上下文中,以供后续函数的调用。同时我们在defer中,最后将使用完毕的logger,放回pool池内,实现复用。

Recovery

recovery是对全局panic的捕捉,一切经由它执行的后续方法,一旦发生panic都会被它捕捉。虽然graphql中,对于每个字段的解析函数,都进行了recover,但是为了以防万一在其他地方出现panic,我们仍然需要在入口处进行recover。

向middleware中添加内容。

func Recovery() graphql.HandlerFunc {
    return func(c *graphql.Context) {
        logger := c.Value("logger").(zerolog.Logger)
        defer func() {
            if r := recover(); r != nil {
                logger.Error().Caller(4).Interface("[Recovery] panic received", r).Send()
                c.ServerError("服务错误", http.StatusInternalServerError)
            }
        }()
        c.Next()
    }
}

这里只做了一个简单的实现,将panic recover起来,打印panic信息,并返回客户端内部错误的状态码。

Tx

在入口处开启一个贯穿整个调用的事务,并对事务的提交和回滚进行自动处理,听起来似乎并不是一个好的选择。因为有些时候,我们的解析函数并不需要事务。
但是鉴于graphql中解析函数的分散,我们可能在一次调用中,对多个字段进行了请求,而这些字段又都对数据库进行了修改,为了保证事务的一致,在简书项目中,还是决定对事务进行全局的管理。

向middleware添加内容。

func Tx(ld *sqlog.DB) graphql.HandlerFunc {
    db := ld.Runner.(*sqlx.DB)
    return func(c *graphql.Context) {
        logger := c.Value("logger").(zerolog.Logger)
        tx, err := db.Beginx()
        if err != nil {
            logger.Error().AnErr("transition begin failed", err).Send()
            c.ServerError("服务错误", http.StatusInternalServerError)
            return
        }
        c.Set("tx", &sqlog.DB{Runner: tx, Logger: ld.Logger})
        defer func() {
            if c.Err() != nil {
                tx.Rollback()
                return
            }
            if err := tx.Commit(); err != nil {
                logger.Error().AnErr("transition commit failed", err).Send()
                tx.Rollback()
            }
        }()
        c.Next()
    }
}

<br/>
最后,我们修改main函数,将三个middleware注册到graphql中。

graphql.Use(middleware.Logger(), middleware.Tx(model.DB), middleware.Recovery())

字段上的middleware

我们知道,在流行的Go Web框架,如Gin,Echo中,都是基于restful标准的。路由即资源。这些框架都提供了,对单个路由进行处理的handler。
在Gin中,一个路由接收多个handler,并以顺序执行,以此实现对单个路由的middleware。

其中比较常用的是BasicAuth这样用于权限校验的handler。

但是GraphQL本身只是一套api标准,并不具备这样的能力。GraphQL官方提供的示例中,我们需要在解析函数内部,从参数或者上下文中,获取验证所需的条件,并进行判断。
这样无疑会产生很多重复的代码逻辑。

var postType = new GraphQLObjectType({
  name: ‘Post’,
  fields: {
    body: {
      type: GraphQLString,
      resolve: (post, args, context, { rootValue }) => {
        // return the post body only if the user is the post's author
        if (context.user && (context.user.id === post.authorId)) {
          return post.body;
        }
        return null;
      }
    }
  }
});

当然,万能的使用者总会有办法的。在GraphQL标准中,实现者应该提供用户自定义Directive的能力。这些自定义的Directive可以被用于类型的定义,或者具体的请求上。
我们通过使用自定义的Directive,也能达到handler的效果。

query($id:String!) {
    User @auth(id:$id) {
        name
    }
}

譬如上述例子,我们对User字段使用了自定义的Directive auth,在auth中进行权限的校验。

但遗憾的是,graphql对自定义directive的支持并不好,目前仅限于在Field类型上使用自定义的Directive,而且存在诸多限制。

所以参考于各web框架对路由的定义,graphql假定每个被定义的Object的字段,都同效于路由,那么自然也可以使用类似的机制。

于是graphql提供了另类的只能用在FieldFunc中的HandlerFunc。

type ExecuteFunc func(ctx context.Context, args, source interface{}) error

ExecuteFunc支持在解析函数执行之前,对原始的map类型的args和source进行处理,并最终返回一个错误,用于终止后续解析函数的执行。

BasicAuth

现在,我们来编写基础的权限校验。

首先我们要明确,我们对于用户的session要如何处理,这关系到我们怎么进行权限的验证。

简书项目中,我们将会把session信息存储在客户端的cookie中。为了简单实现,session信息只包括用户的id。但是我们并不是要把id直接存储到cookie中去。
而是要经过加密。这里我们使用jwt进行加密认证。

在util包下,新建jwt.go文件。

package util

import (
    "fmt"
    "github.com/dgrijalva/jwt-go"
    "github.com/shyptr/jianshu/setting"
    "strconv"
    "time"
)

func GeneraToken(id uint64, age int) (string, error) {
    claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
        ExpiresAt: int64(time.Hour) * int64(age),
        Id:        fmt.Sprintf("%d", id),
    })
    return claims.SignedString(setting.GetJwtSecret())
}

func ParseToken(token string) (uint64, error) {
    claims, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, func(*jwt.Token) (interface{}, error) {
        return []byte(setting.GetJwtSecret()), nil
    })
    if err != nil {
        return 0, err
    }
    standardClaims := claims.Claims.(*jwt.StandardClaims)
    expiresAt := standardClaims.VerifyExpiresAt(time.Now().Unix(), true)
    if !expiresAt {
        return 0, errors.New("token失效")
    }
    return strconv.ParseUint(standardClaims.Id, 10, 0)
}

向middleware中添加内容。

func BasicAuth() schemabuilder.ExecuteFunc {
    return func(ctx context.Context, args, source interface{}) error {
        c := ctx.(*graphql.Context)
        logger := c.Value("logger").(zerolog.Logger)
        cookie, _ := c.Request.Cookie("me")
        if cookie == nil {
            return nil
        }
        token := cookie.Value
        if token != "" {
            id, err := util.ParseToken(token)
            if err != nil {
                logger.Error().AnErr("basicAuth parse token", err).Send()
                return errors.New("解析token失败")
            }
            c.Set("userId", id)
        }
        return nil
    }
}

func LoginNeed() schemabuilder.ExecuteFunc {
    return func(ctx context.Context, args, source interface{}) error {
        id := ctx.Value("userId")
        if id == nil {
            c := ctx.(*graphql.Context)
            c.ServerError("必须先登录", http.StatusUnauthorized)
            return errors.New("你必须先登录")
        }
        return nil
    }
}

func NotLogin() schemabuilder.ExecuteFunc {
    return func(ctx context.Context, args, source interface{}) error {
        id := ctx.Value("userId")
        if id != nil {
            c := ctx.(*graphql.Context)
            c.ServerError("必须先退出登录", http.StatusMethodNotAllowed)
            return errors.New("你必须先退出登录")
        }
        return nil
    }
}

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

作者微信公众号:


云燕
0 声望2 粉丝