golang + GraphQL 踩坑

by kingzcheung on April 21, 2019

GraphQL 是一种用于API的查询语言,根据官方描述,GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述,使得客户端能够准确地获得它需要的数据,而且没有任何冗余,也让 API 更容易地随着时间推移而演进,还能用于构建强大的开发者工具。

REST vs GraphQL ?

提到GraphQL ,就不得不REST 比较,REST是当下非常流行的接口风格约定,但是 RESTful的API 一般会存在下面这些问题:

  • 字段冗余:一般后台为了适应接口在各个页面的适配性,都会尽量的给出更多的字段,但是这在页面中很可能很多字段都是不需要的。这造成了网络的传输量比较大。
  • 一个请求无法解决所有数据:一般考虑到页面的复杂性,前端都需要请求多个 RESTful API 来获取多方数据。这就造成了一个页面请求多个接口才能获取相应的数据。
  • 需要大量时间维护额外的接口文档

而 GraphQL 刚好没有以上问题。那么GraphQL 值不值得推荐呢?作为后端开发者,我们应该先试用过,心里才有更好的答案。下面使用 golang + GraphQL 为例,golang web 框架使用 echo

我们需要把构建Scheme对象,这个对象有两个特殊的类型:querymutation:

var Schema, _ = graphql.NewSchema(
    graphql.SchemaConfig{
        Query:    queryType,
        Mutation: mutationType,
    },
)

var queryType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Query",
    Fields: graphql.Fields{
        "user":           &user.ListUsers,
    },
})

var mutationType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Mutation",
    Fields: graphql.Fields{
        "createUser":      &user.CreateUser,
    },
})

然后为以上的字段userscreateUser构建对应的对象以及数据处理:

var (
    userType = graphql.NewObject(graphql.ObjectConfig{
        Name:        "User",
        Description: "User Model",
        Fields: graphql.Fields{
            "id":         &graphql.Field{Type: graphql.Int},
            "email":      &graphql.Field{Type: graphql.String, Description: "email"},
            "username":   &graphql.Field{Type: graphql.String, Description: "用户名"},
            "is_admin":   &graphql.Field{Type: graphql.Int, Description: "是不是管理员"},
            "created_at": &graphql.Field{Type: graphql.DateTime},
            "updated_at": &graphql.Field{Type: graphql.DateTime},
            "deleted_at": &graphql.Field{Type: graphql.DateTime},
        },
    })
    
    QueryUser = graphql.Field{
        Name:        "QueryUser",
        Description: "通过 email 或者用户名查询某一个用户的信息",
        Type:        userType,
        Args: graphql.FieldConfigArgument{
            "id":       &graphql.ArgumentConfig{Type: graphql.Int,},
            "username": &graphql.ArgumentConfig{Type: graphql.String,},
            "email":    &graphql.ArgumentConfig{Type: graphql.String,},
        },
        Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
            //业务逻辑实现
            where := make(map[string]interface{}, 3)
            id, ok := p.Args["id"].(int)
            if ok {
                where["id"] = id
            }
            username, ok := p.Args["username"].(string)
            if ok {
                where["username"] = username
            }
            email, ok := p.Args["email"].(string)
            if ok {
                where["email"] = email
            }

            return server.User(where)
        },
    }
    
    //CreateUser 创建用户
    CreateUser = graphql.Field{
        Name:        "CreateUser",
        Type:        userType,
        Description: "创建用户",
        Args: graphql.FieldConfigArgument{
            "username": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
            "email": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
            "password": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
            "is_admin": &graphql.ArgumentConfig{
                Type: graphql.Int,
            },
        }
        
        Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
            
            username, _ := p.Args["username"].(string)
            email, _ := p.Args["email"].(string)
            password, _ := p.Args["password"].(string)
            isAdmin, _ := p.Args["is_admin"].(int64)
            //业务逻辑实现
            pwd, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

            user := model.User{
                Username: username,
                Email:    email,
                Password: string(pwd),
                IsAdmin:  isAdmin,
            }
            err := server.CreateUser(user)
            user.Password = ""
            return user, err
        },
    }

)

可以从代码构建知道为什么graphql 不写文档,因为字段解释和接口解释都已经在代码中的相关字段有所声明,并通过 GET /graphql 页面来做相关的查询。我们在Resolve实现相关的业务逻辑。

最后我们需要把Schema添加到echo 框架中HTTP Handler 中,通过 echo 编写一个web,并添加graphql路由:

graph := e.Group("graphql")
    {
        graph.POST("", GraphqlHandler())
        graph.GET("", GraphqlHandler())
    }

func GraphqlHandler() echo.HandlerFunc {
    h := handler.New(&handler.Config{
        Schema:   &schema.Schema,
        Pretty:   true,
        GraphiQL: true,
    })
    return func(c echo.Context) error {
        //h.ContextHandler(c.Request().Context(),c.Response(),c.Request())
        h.ServeHTTP(c.Response(), c.Request())
        return nil
    }
}

最后我们就可以在/graphql中查询到我们定义过的数据。

一些疑问

有人可能注意到,我仅仅是实现一个非常简单的查询user和创建createUser就这么多的代码量,而且因为 golang的没有泛型支持和反射有点弱,导致整个代码写下来非常的丑陋。事实上,就算放在别的语言,比较PHP,或者java,它的代码量应该也比正常的REST多,因为需要对每个字段都要做相应的处理,这导致后端代码量工作量非常的大。

事实上在上述的开发中对于golang + graphql组合而言,还遇到了一些问题:

  • 分页需要额外的struct 包装才比较方便的输出数据,后端灵活性很低,需要额外的处理。
  • graphql-go/graphql 这个库无法在Resolve 中获取到原始请求头,比如接口需要获取请求头再进行业务逻辑的处理的话,Resolve 是无法获取到请求头的,这需要额外的处理,当然这不是graphql的问题。
  • 会比较难debug,graphql-go/graphql 隐藏了很多细节,通过此库构建的对象一旦和golang struct 字段无法完全对上,就无法查询到数据,刚接触的时候,很难判断到底是哪里出了问题。

总结

Graphql 确实解决了REST 的一些痛点,并且前端使用起来非常的爽。但是后端实现成本比REST高得多,为作一个后端开发人员,我还是会选择REST而不是Graphql。或者Graphql + nodejs才是最佳选择。