1、前言
在目前比较流行的微服务架构中,一般为了将存储和应用解耦,会将存储作为单独的服务提供给应用使用,由存储服务对接底层各种存储引擎,如MySQL、MongoDB、PostgreSQL等。这样可以统一各种存储引擎的差异,使应用对存储引擎无感知。要消除这种差异,需要制定一个统一的接口语言,应用使用该接口语言来操作数据。GraphQL是Facebook开源的一种接口语言,它可以提供灵活、高效的数据操作方式,能够精确的获取指定数据。本文接下来介绍GraphQL及用于构建其Go服务的gqlgen库的使用,最后会涉及到针对gqlgen的改造,以方便管理大型项目。其中gqlgen的改造代码参考:https://github.com/jemuelmiao/gqlgen。
2、基本用法
(1)创建GraphQL项目
> mkdir example && cd example > go mod init example > go get github.com/99designs/gqlgen > go run github.com/99designs/gqlgen init > go mod tidy
创建后项目目录如下:
(2)调整请求处理逻辑:schema.resolvers.go
(3)启动服务:go run server.go
(4)请求数据
浏览器访问http://localhost:8080展示结果如下:
同样也可以通过调用API(http://localhost:8080/query)请求数据:
(5)修改结构定义
修改schema定义文件后,需执行go run github.com/99designs/gqlgen generate重新生成代码。
3、GraphQL类型
3.1 内置类型
GraphQL中类型分为几种:基本类型、扩展类型、自定义类型。其中基本类型包括:Float、String、Boolean、Int、ID,扩展类型包括:Time、Map、Upload、Any、Uint。这些类型和Go类型的映射关系如下:
在schema文件中使用类型有三个步骤:实现Go类型序列化方法、在gqlgen.yml中映射GraphQL scalar到Go类型、在schema中增加GraphQL scalar。其中基本类型在schema中可以直接使用,无需经过这三个步骤。扩展类型(除Uint外)已经在gqlgen中实现序列化和到Go类型的映射,使用时只需在schema中增加GraphQL scalar即可。Uint在gqlgen中实现了序列化,但未映射到Go类型,使用时需要手动映射到Go类型以及在schema中增加GraphQL scalar。gqlgen源码中各类型实现在目录gqlgen/graphql中,内置类型映射在gqlgen/codegen/config/config.go的injectBuiltins函数中。
3.2 自定义类型
当GraphQL提供的类型无法满足需要时,可以通过自定义类型提供。自定义类型有两种:用户定义类型、三方库类型。两者的区别在于用户是否能够修改类型,为其增加方法。下面介绍如何扩展类型。
3.2.1 用户定义类型
(1)实现Go类型
在github上建立一个仓库用于管理自定义类型,实现graphql.Marshaler和graphql.Unmarshaler两个接口或graphql.ContextMarshaler和graphql.ContextUnmarshaler两个接口,两者的主要区别是后者在类型序列化过程中需要使用请求的context。示例仓库如https://github.com/jemuelmiao/gqlgen_type,自定义类型Long、LongContext实现分别在文件type_long.go、type_long_context中。
graphql.Marshaler和graphql.Unmarkshaler接口定义如下:
type Marshaler interface { MarshalGQL(w io.Writer) } type Unmarshaler interface { UnmarshalGQL(v interface{}) error }
graphql.ContextMarshaler和graphql.ContextUnmarshaler接口定义如下:
type ContextMarshaler interface { MarshalGQLContext(ctx context.Context, w io.Writer) error } type ContextUnmarshaler interface { UnmarshalGQLContext(ctx context.Context, v interface{}) error }
自定义Long类型实现如下:
package gqlgen_type import ( "fmt" "io" ) type Long int64 func (l Long) MarshalGQL(w io.Writer) { w.Write([]byte(fmt.Sprintf("%v", l))) } func (l *Long) UnmarshalGQL(v interface{}) error { value, ok := v.(int64) if !ok { return fmt.Errorf("Long must be int64") } *l = Long(value) return nil }
自定义LongContext类型实现如下:
package gqlgen_type import ( "context" "fmt" "io" ) type LongContext int64 func (l LongContext) MarshalGQLContext(ctx context.Context, w io.Writer) error { w.Write([]byte(fmt.Sprintf("%v", l))) return nil } func (l *LongContext) UnmarshalGQLContext(ctx context.Context, v interface{}) error { value, ok := v.(int64) if !ok { return fmt.Errorf("LongContext must be int64") } *l = LongContext(value) return nil }
(2)映射GraphQL scalar到Go类型
在gqlgen.yaml中增加:
models: Long: model: github.com/jemuelmiao/gqlgen_type.Long
(3)增加GraphQL scalar
在schema文件中增加:scalar Long
(4)使用scalar类型
在schema文件中定义字段类型:
type UserType { field1: Long }
3.2.2 三方库类型
该类型和用户定义类型除了实现序列化方式不一样外,其他都一样,例如类型LongThirdParty实现方式为:
package gqlgen_type import ( "fmt" "github.com/99designs/gqlgen/graphql" "io" ) func MarshalLongThirdParty(v int64) graphql.Marshaler { return graphql.WriterFunc(func(w io.Writer) { w.Write([]byte(fmt.Sprintf("%v", v))) }) } func UnmarshalLongThirdParty(v interface{}) (int64, error) { value, ok := v.(int64) if !ok { return 0, fmt.Errorf("LongThirdParty must be int64") } return value, nil }
4、GraphQL指令
GraphQL指令类似装饰器,主要用于实现一些通用的功能,如字段tag、有效性检查、API权限检查等,GraphQL提供了几个内置指令,另外也可以自己扩展指令。
4.1 内置指令
内置指令包括:goModel、goField、goTag。其中goModel用于将Go结构映射为一个GraphQL模型,goField用于将Go结构字段映射为GraphQL模型字段,goTag用于自定义GraphQL模型字段的标签。
使用内置指令前需在schema文件中定义,定义如下:
directive @goModel( model: String models: [String!] ) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION directive @goField( forceResolver: Boolean name: String omittable: Boolean ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION directive @goTag( key: String! value: String ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
使用方式如下:
type User @goModel(model: "github.com/my/app/models.User") { id: ID! @goField(name: "todoId") name: String! @goField(forceResolver: true) @goTag(key: "xorm", value: "-") @goTag(key: "yaml") }
4.2 自定义指令
GraphQL除了内置指令外,还支持自定义指令。接下来通过字段校验来介绍如何创建自定义指令。
(1)在schema文件中定义指令
directive @validate(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
(2)在待校验字段上加入校验规则
input CreateProjectReq { name: String! @validate(constraint: "required,max=20") desc: String @validate(constraint: "max=100") task_count: Int64 @validate(constraint: "gte=0") segment_count: Int64 @validate(constraint: "gte=0") stage_ids: [Int32]! }
(3)重新生成代码
go run github.com/99designs/gqlgen generate
(4)增加指令处理逻辑
... conf := graph.Config{ Resolvers: &resolver.Resolver{}, } conf.Directives.Validate = func(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (res interface{}, err error) { val, err := next(ctx) if err != nil { panic(err) } fieldName := *graphql.GetPathContext(ctx).Field err = validate.Var(val, constraint) if err != nil { validationErrors := err.(validator.ValidationErrors) return nil, fmt.Errorf("%v:%v", fieldName, validationErrors[0].Translate(trans)) } return val, nil } srv := handler.NewDefaultServer(graph.NewExecutableSchema(conf)) ...
(5)重启GraphQL服务器
go run server.go
(6)验证
5、GraphQL插件
5.1 插件开发
gqlgen提供了接入插件方式调整代码生成规则,生成代码时,会优先初始化内置插件,然后初始化用户插件,具体可参考https://github.com/99designs/gqlgen/blob/master/api/generate.go。gqlgen使用的内置插件有modelgen、resolvergen、federation(执行顺序:modelgen=》resolvergen=》federation=》用户插件)。插件可以调整的内容有几种类型:MutateConfig、GenerateCode、InjectSourceEarly、InjectSourceLate、Implement(类型执行顺序:InjectSourceEarly=》InjectSourceLate=》MutateConfig=》GenerateCode=》Implement),具体可参考https://github.com/99designs/gqlgen/blob/master/plugin/plugin.go,开发插件时可以根据插件功能实现其中一种或多种类型,其中常见的是MutateConfig、GenerateCode,MutateConfig用于在生成代码前修改配置,GenerateCode用于修改生成的代码。
开发插件时根据功能实现https://github.com/99designs/gqlgen/blob/master/plugin/plugin.go中一个或多个函数,具体实现方式可以参考内置插件https://github.com/99designs/gqlgen/blob/master/plugin/resolvergen/resolver.go。插件开发完成后,接入GraphQL服务器方式如下:
func main() { ... cfg, err := config.LoadConfigFromDefaultLocations() if err != nil { os.Exit(1) } var options []api.Option options = append(options, api.AddPlugin(yourplugin.New())) err = api.Generate(cfg, options...) if err != nil { os.Exit(2) } ... }
6、实战用法
6.1 二次改造
原生gqlgen生成的组织结构不满足大型项目需求,所有resolver文件全部在一个目录,对于单一项目来说没问题,但如果作为多个项目的统一接入层,显然这种文件组织方式会造成混乱,不利于维护管理。因此需要对gqlgen进行改造,改造需求:graphql文件按照项目子目录形式管理,生成的resolver文件按照相同的子目录形式组织,支持共用的resolver文件,graphql文件以及对应的resolver文件组织结构示例如下:
原始graphql文件由两个项目datamanager、datamark组成,里面包含各自项目的结构定义,另外顶层schema目录中包含了所有项目共用的结构定义文件comm.graphql。最终需要生成的resolver目录也要求按照各自项目进行组织,公共文件在顶层目录中。
整个改造以插件形式接入,原版gqlgen有个内嵌插件resolvergen用于生成resolver相关代码,其调用是在api/generate.go/Generate()中,现在需要将其替换为新插件resolvergen_v2。改造相关的代码见:https://github.com/jemuelmiao/gqlgen,可查看提交日志查看针对原版gqlgen做了哪些改动。
6.1.1 总入口resolver.go
resolver.go文件中包含了所有graphql中定义的Query、Mutation,这个文件在原版gqlgen中只包含了type Resolver struct{},改造后需要将各个项目中所有Query、Mutation进行定义,然后通过导入子模块的方式调用每个模块的具体实现。
为方便生成resolver.go,需为resolver.go创建一个新的代码模板,这里我命名为resolver_interface.gotpl,具体内容见:https://github.com/jemuelmiao/gqlgen/blob/main/plugin/resolvergen_v2/resolver_interface.gotpl。
在插件generatePerSchema函数中,将mutationResolver、queryResolver的定义加入生成的resolver.go中,并且针对各个项目中定义的Query、Mutation函数,通过import方式将对应函数引入并调用,为了防止子模块重名,采用alias方式引入子模块,另外在顶层目录的公用文件因为和resolver.go在同级目录,可以直接调用公用文件的函数,无需import,因此需保证公用部分不能有重名函数,这也是很合理的。具体实现见:https://github.com/jemuelmiao/gqlgen/blob/main/plugin/resolvergen_v2/resolver.go。生成的resolver.go示例如下:
6.1.2 自定义结构的resolver
自定义Query、Mutation函数包含了具体实现,这些函数在原版gqlgen中是通过mutationResolver、queryResolver的方法定义的,改造后为单独的函数。为方便生成每个自定义Query、Mutation函数,需为他们创建一个统一的代码模板,这里我命名为resolver_schema.gotpl,具体内容见:https://github.com/jemuelmiao/gqlgen/blob/main/plugin/resolvergen_v2/resolver_schema.gotpl。
在插件generatePerSchema函数中,生成每个Query、Mutation函数的定义及实现,首次生成时会使用默认实现,再次生成时会使用已有实现。具体实现见:https://github.com/jemuelmiao/gqlgen/blob/main/plugin/resolvergen_v2/resolver.go。生成的文件示例如下:
6.1.3 重写所有resolver
上面提到再次生成每个Query、Mutation的函数时,需要保证已有的实现代码不丢失,因此还需要改造resolver重写逻辑,原版gqlgen重写逻辑是针对单个package,需要将其调整为支持多个package,用于区分不同子模块,调整后结构如下:
type MultiRewriter struct { pkgs map[string]*packages.Package // {dirpath: package} files map[string]string copied map[ast.Decl]bool }
pkgs通过子模块路径名进行区分。具体实现见:https://github.com/jemuelmiao/gqlgen/blob/main/internal/rewrite/multirewriter.go。
6.2 统一存储
在实际项目中,通常会用到各种各样的存储服务,不同存储服务千差万别,对接成本和切换成本较高,为降低应用层对存储服务的依赖,可以抽象出存储中间层,将各种存储服务管理起来,对外提供统一的接口,架构图如下:
在使用统一存储时,可以在生成的resolver函数中使用合适的存储引擎操作数据,使用MongoDB示例如下(实际使用时可以封装各种引擎,方便切换):