实践微服务,第14部分:GraphQL

在实践微服务系列博客的这一篇中,我们将看看如何使用GraphQL将Account对象提供给我们的客户端。

内容

  1. 概述
  2. GraphQL
  3. graphql-go的模式,字段和类型
  4. 解析函数
  5. 查询
  6. 单元测试
  7. 通过HTTP提供服务
  8. 概要

源代码

完成的源代码可以从GitHub克隆:

> git clone https://github.com/callistaenterprise/goblog.git
> git checkout P14

1.概述

系列博客的这一篇不会引入任何新的服务,它只会将新的/graphqlPOST端点添加到“accountservice”,该端点将按照我们将在Go代码中定义的graphql模式来响应请求。

2. GraphQL

GraphQL由Facebook在内部开发并于2015年公开发布。它为RESTful和其他架构提供了用于从服务器向客户端提供数据的另一种查询语言。也许最独特的特点是GraphQL允许客户端定义如何构造请求的数据,而不是让服务器决定。这意味着客户端可以准确地获取所需的数据,从而缓解这个经典问题,为手头用例获取到的数据太多或太少。

我建议你深入了解官方文档以获取更详细的解释。

3.graphql-go的模式

我们来快速浏览一下使用关键概念设置GraphQL模式(schema),例如类型(types),字段(fields),解析函数(resolve functions),根查询(root queries)和结果模式(resulting schema)。

3.1模式,类型和字段

模式定义了可以在GraphQL查询中使用的类型和字段。GraphQL不受任何特定的DSL或编程语言的束缚。由于这是一个Go博客,我将根据GitHub上的graphql-go/graphql项目使用Go语言GraphQL模式。

以下是我们在系列博客的第13篇中介绍的“AccountEvent”的GraphQL类型定义:

var accountEventType = graphql.NewObject(   // Create new object
    graphql.ObjectConfig{                   // Declare object config
        Name: "AccountEvent",               // Name of the type
        Fields: graphql.Fields{             // Map declaring the fields of this type
            "id": &graphql.Field{           // Field declaration, "id" is its name 
                Type: graphql.String,       // of type string.
            },
            "eventName": &graphql.Field{
                Type: graphql.String,
            },
            "created": &graphql.Field{
                Type: graphql.String,
            },
        },
    }
)

上面的类型声明与GraphQL一样简单,与Go语言的结构声明有所不同。

但是,当我们引入解析函数,参数并将几个声明类型链接到一个组合对象中时,它会变得更加复杂。

下面是我们的“Account”类型的一个稍微简化的类型声明,它差不多反映了我们当前使用的输出结构:

// accountType, includes Resolver functions for inner quotes and events.
var accountType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Account",
    Fields: graphql.Fields{
        "id": &graphql.Field{                // The id, name and servedBy fields should be familiar
            Type: graphql.String,
        },
        "name": &graphql.Field{
            Type: graphql.String,
        },
        "servedBy": &graphql.Field{
            Type: graphql.String,
        },
        // continued...

第一部分与我们已经声明的“accountEventType”非常相似,只是一个类型的“原始”字段,没什么大不了的。

但是,当我们声明“accountType”包含一个“accountEventType”的 列表(List)解析(Resolve)函数时,“accountType”声明的下一部分变得更加复杂。

稍后查看GraphQL查询时,我们将看到实际Account对象的解析函数。在这种情况下,解析函数是实际从某些数据源(BoltDB,Hard-coded,CockroachDB ...)中提取Account结构(或其他)的代码片段,并将该数据填充到GraphQL运行时中,以确保输出的数据符合具有查询请求的结构。

下面的解析函数对已获取的数据(accounts)进行操作,在指定了如下内容时使用“eventName”参数对每个项目的“events”进行过滤查询:

      "events": &graphql.Field{                      // Here's how we declare that our "account" type can contain  
            Type: graphql.NewList(accountEventType),   // a list field. Declare "events" as type List of the accountEvent
                                                       // type we declared in the last code sample.
            Args: graphql.FieldConfigArgument{         // Args declare _what_ fields we allow queries to use when filtering
                "eventName": &graphql.ArgumentConfig{  // this sublist in the context of the parent account type.
                    Type: graphql.String,
                },
            },
                                                       // Resolve functions on types allows us to use declared (and possibly supplied) 
                                                       // args in order to perform filtering of items from a sub-list.
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                account := p.Source.(internalmodel.Account)     // Get the struct we're performing filtering on.
                events := make([]model.AccountEvent, 0)         // Create a new slice to return the wanted accountEvents in.
                for _, item := range account.AccountEvents {    // Iterate over all accountEvents on this account.
                    if item.EventName == p.Args["eventName"] {  // Add to new list only if predicate is true
                        events = append(events, item)
                    }
                }
                return events, nil                             // Return the new list.
            },    
        },                                                   
        // truncated for brevity...

解析函数是我掌握的最难的部分,在查看真正的帐户查询的解析函数时,我们会稍微看到更多的解析代码。

3.2将模式放在一起

为了让客户能够获取账户对象,我们需要创建一个由SchemaConfig组成的模式,其中包含一个指定可查询字段RootQuery

Schema <- SchemaConfig <- RootQuery <- Field(s)

在Go代码中它是这样声明的:

rootQuery := graphql.ObjectConfig{Name: "RootQuery", Fields: fields}
schemaConfig := graphql.SchemaConfig{Query: graphql.NewObject(rootQuery)}
var err error
schema, err = graphql.NewSchema(schemaConfig)

看起来似乎很简单。但真正的复杂性在fields参数中。我们将声明一个名为“Account”的字段:

// Schema
fields := graphql.Fields{
    "Account": &graphql.Field{
        Type: graphql.Type(accountType),               // See accountType above
        Args: graphql.FieldConfigArgument{
            "id": &graphql.ArgumentConfig{
                Type: graphql.String,
            },
            "name": &graphql.ArgumentConfig{
                Type: graphql.String,
            },
        },
        Resolve: resolvers.AccountResolverFunc,
    },
}

重要的是这看起来非常像我们已经声明的东西。

我对于上面看到的代码的解释是声明的“Account”字段是在RootQuery上的查询。

  • 这个“Account”字段由一个单一的GraphQL类型“accountType”组成 - 例如我们上面定义的类型。
  • “Account”定义了两个可用于查询帐户的参数 - id和name。
  • “Account”定义了由另一个包中的命名函数引用提供的解析函数。

带有字段和类型的最终模式由我来说可以像这样表示:

如果我们想要一个返回账户列表的GraphQL查询,可以声明另一个字段,例如:

"AllAccounts": &graphql.Field{
    Type: graphql.NewList(accountType),          // List of accountType objects
    Args: graphql.FieldConfigArgument{
        "name": &graphql.ArgumentConfig{
            Type: graphql.String,
        },
    },
    Resolve: resolvers.AllAccountsResolverFunc,   // Some function that returns all accounts
},

RootQuery上的该字段将类型指定为accountType的List。在指定的“AllAccountsResolverFunc”函数中,可以将单个“name”参数实现为一个“like”搜索或类似的查询。

4.解析器的实现和测试

那么,现在我们已经将模式放在一起了,我们如何将底层数据模型绑定到在“解析器”参数中声明的解析函数?(它作为参数传递给设置所有这些东西的函数)

一件美妙的事情是我们可以向解析函数传递“鸭子类型”的

functionName(p graphql.ResolveParams) (interface{}, error)

签名使我们可以轻松地为单元测试和实际实现提供不同的实现。这是使用良好的go接口和实现完成的:

// GraphQLResolvers defines an interface with our Resolver function(s)
type GraphQLResolvers interface {
AccountResolverFunc(p graphql.ResolveParams) (interface{}, error)
}
// LiveGraphQLResolvers - actual implementation used when running outside of unit tests.
type LiveGraphQLResolvers struct {
}
func (gqlres *LiveGraphQLResolvers) AccountResolverFunc(p graphql.ResolveParams) (interface{}, error) {
account, err := fetchAccount(p.Context, p.Args["id"].(string))
if err != nil {
return nil, err
}
return account, nil
}
// TestGraphQLResolvers - implementation used in unit tests.
type TestGraphQLResolvers struct {
}
func (gqlres *TestGraphQLResolvers) AccountResolverFunc(p graphql.ResolveParams) (interface{}, error) {
id, _ := p.Args["id"].(string)
name, _ := p.Args["name"].(string)
for _, account := range accounts {                     // The accounts slice is declared elsewhere in the same file as test data.
if account.ID == id || account.NAME == name {
return account, nil
}
}
return nil, fmt.Errorf("No account found matching ID %v or Name %v", id, name)
}

“live”实现使用“fetchAccount”函数真正地与其他微服务(dataservice,quotes-service,imageservice)通信以获取请求的账户对象。除了一些重构,确保我们的旧/account/ {accountId}HTTP端点使用和的新的用GraphQL解析函数“”fetchAccount”函数相同的代码来获取帐户对象之外,没有新的东西。

  • “test”实现使用一个硬编码的Account对象的切片(slice),并在匹配任一参数时返回。

使用的解析器实现只是在调用代码提供的内容。在单元测试中:

func TestFetchAccount(t *testing.T) {
    initQL(&TestGraphQLResolvers{}) // Test implementation passed.
    ....

当从主函数启动时 ( 即运行独立或在Docker容器中启动时),则会调用这一行:

initQL(&LiveGraphQLResolvers{})

5. GraphQL查询

到目前为止,我们只是奠定了基础。毕竟,GraphQL的目的是为系列博客第2篇中提到的动态查询提供便利。

GraphQL 查询的基本形式只需要查询模式中声明的对象的指定字段。例如,如果我们想要一个仅包含“name”和events的Account对象,则查询将是这样的:

query FetchSingleAccount {         // Query and an arbitrary name for the query. This is optional!!                          
Account(id: "123") {           // We want to query the "Account" field on the RootQuery having id "123"
    name, events{              // Include the "name" and "events" fields in the response.
        eventName,created      // On the "events", include only eventName and created timestamp.
        }
    }
}

响应就像这样:

{
    "data":{
        "Account":{
            "name":"Firstname-2483 Lastname-2483",
            "events":[{
                "created":"2018-02-01T15:26:34.847","eventName":"CREATED"
            }]
        }
    }
}

请注意,我们必须指定我们想要的events的字段,否则会返回以下错误:

"Field "events" of type "[AccountEvent]" must have a sub selection.",

我们将在下面的单元测试部分看到一个更复杂的例子。

使用GraphQL查询可以做很多事情,可以在这里查看分片,参数,变量等。

6.单元测试

我们如何断言我们的模式实际上是以一种有效的方式建立的并且我们的查询会好好工作?单元测试拯救世界!

所有的GraphQL代码已经写入文件/accountservice/service/accountql.go,因此它的相应单元测试位于/accountservice/service/accountql_test.go

我们首先指定一个GraphQL查询为一组多行字符串。查询使用了变量、字段选择和传递给quote和events子字段的参数。

var fetchAccountQuery = `query fetchAccount($accid: String!) {
    Account(id:$accid) {
        id,name,events(eventName:"CREATED") {
            eventName
        },quote(language:"en") {
            quote
        },imageData{id,url}
    }
}`

接下来,测试函数:

func TestFetchAccount(t *testing.T) {
    initQL(&TestGraphQLResolvers{})                                       // #1 Init GraphQL schema with test resolvers
    Convey("Given a GraphQL request for account 123", t, func() {
        vars := make(map[string]interface{})                              // #2 Variables
        vars["accid"] = "123"
        // #3 Create parameters object with schema, variables and the query string
        params := graphql.Params{Schema: schema, VariableValues: vars, RequestString: fetchAccountQuery}
        Convey("When the query is executed", func() {
            r := graphql.Do(params)                       // #4 Execute the query   
            rJSON, _ := json.Marshal(r)                   // #5 Transform the response into JSON
            Convey("Then the response should be as expected", func() {
                // #6 Assert stuff...
                So(len(r.Errors), ShouldEqual, 0)         
                So(string(rJSON), ShouldEqual, `{"data":{"Account":{"events":[{"eventName":"CREATED"}],"id":"123","imageData":{"id":"123","url":"http://fake.path/image.png"},"name":"Test Testsson 3","quote":{"quote":"HEJ"}}}}`)
            })
        })
    })
}
  1. 我们做的第一件事就是调用initQL函数,并通过我们的测试 Resolver实现。这个initQL 函数是我们在第三节中看到过的一个,设置了我们的模式,字段等内容。
  2. 我们声明了一个用于将变量传递到查询执行中的String => interface {}映射。
  3. graphql.Params包含模式,变量和我们要执行的实际查询。
  4. 该查询通过将param对象传入graphql.Do(...) func来执行。
  5. 将响应转换为JSON
  6. 断言没有错误和预期的输出。

上面的测试结构使得编写查询并根据模式测试它们非常简单。实际的输出当然会有所不同,具体取决于您的TestResolver使用的测试数据以及它们如何处理传递给它们的参数。

7.连接GraphQL HTTP端点

除非我们能够为我们的服务的消费者提供功能,否则所有这些GraphQL都是无用的。现在是将GraphQL功能连接到HTTP路由器的时候了!

7.1代码设置

我们来看看/accountservice/service/routes.go,其中声明了一个新的/graphql路由:

Route{
    "GraphQL",  // Name
    "POST",     // HTTP method
    "/graphql", // Route pattern
    gqlhandler.New(&gqlhandler.Config{
        Schema: &schema,
        Pretty: false,
    }).ServeHTTP,
},

实际上非常简单 - 端点将查询作为POST主体,处理函数由graphql-go/handler提供,该程序接受我们的模式(在同一包中的accountql.go中声明)作为参数。

我们只需要确保initQL(..)在我们的实时解析器函数的某个地方被调用,例如在初始化路由之前的router.go中

func NewRouter() *mux.Router {
initQL(&LiveGraphQLResolvers{})               // HERE!!
router := mux.NewRouter().StrictSlash(true)
for _, route := range routes {
    // rest omitted ...

7.2构建并运行

(关于copyall.sh的说明:这次我们的copyall.sh脚本只有一个变化,那就是我们回到了使用标准日志记录而不是我们的小“gelftail”。在使用Logging-as-a-服务是生产环境的必要条件,开发起来有点不方便,因为Docker增加了“docker service logs [servicename]”,现在可以更容易地查看日志,而无需使用docker ps查找容器ID 。 于是,现在我们做回了老式的日志。)

要在运行时环境中测试我们的GraphQL,请启动你的Docker Swarm模式服务器,确保从git检出分支P14并运行./copyall.sh脚本来构建和部署。

与往常一样,部署需要一点时间,但是一旦所有事情都已启动并运行,我们全新的http:// accountservice:6767/graphql端点应该可以实用了。

让我们用curl来尝试一下吧!(请注意,我使用的是192.168.99.100,因为这是我的本地Docker Swarm模式节点的IP)

> curl -d '{Account(id: "ffd508b5-5f87-4246-9867-ead4ecb01357") {name, events{eventName, created}}}' -X POST -H "Content-Type: application/graphql" http://192.168.99.100:6767/graphql
{"data":{"Account":{"events":[{"created":"2018-02-01T15:26:34.847","eventName":"CREATED"}],"name":"Firstname-2483 Lastname-2483"}}}

请注意,我们传入了适当的Content-Type标头。

我还通过向application.yaml添加一个条目,在我们的Zuul EDGE服务器中公开了/graphql端点。所以我们可以通过我们的反向代理来调用它,包括HTTPS终止:

> curl -k -d '{Account(id: "ffd508b5-5f87-4246-9867-ead4ecb01357") {name, events{eventName, created}}}' -X POST -H "Content-Type: application/graphql" https://192.168.99.100:8765/api/graphql

请注意,上述查询中使用的ID用于我的CockroachDB帐户数据库中已存在的帐户。

要获得要查询的帐户ID,我将两个帮助程序GET端点添加到端口7070处公开的“dataservice”。首先,您可以使用一个小的/random端点来获取Account实例,例如:

> curl http://192.168.99.100:7070/random
{"ID":"10000","name":"Person_0","events":[{"ID":"8f1d0b2f-aa78-4672-85e0-5018870de550","eventName":"CREATED","created":"2018-05-06T09:21:06.747"}

如果你的数据库是空的(例如上面的返回一个HTTP 500),那么有可能要使用另一个小实用程序端点/seed

> curl http://192.168.99.100:7070/seed
(wait a while)
{'result':'OK'}

请注意,运行/seed将删除您的CockroachDB中的所有条目!

7.3反思模式

GraphQL的一个非常有用的特性是它能够使用自省向客户描述自己。

通过对__schema和__type进行查询,我们可以获得关于我们声明的模式的信息。这个查询返回模式中的所有类型:

{
  __schema {
    types {
      name
    }
  }
}

响应:

{
    data": {
        "__schema": {
            "types": [
                {
                "name": "Account"
                },
                  {
                "name": "__Type"
                },
                  {
                "name": "Boolean"
                },
                  {
                "name": "__DirectiveLocation"
                },
                  {
                "name": "AccountImage"
                },
                ... omitted for brevity ...
            ],
        }
    }
}

我们还可以仔细看看“账户”类型,毕竟这是我们一般是在这个API中处理的:

{
  __type(name: "Account") {
    name
    fields {
      name
      type {
        name
        kind
        ofType {
          name
          kind
        }
      }
    }
  }
}

响应:

{
"data": {
    "__type": {
    "fields": [
        {
            "name": "events",
            "type": {
                "kind": "LIST",
                "name": null,
                "ofType": {
                    "kind": "OBJECT",
                    "name": "AccountEvent"
                }
            }
        },
        {
            "name": "id",
            "type": {
                "kind": "SCALAR",
                "name": "String",
                "ofType": null
            }
        },
        ... Omitted for brevity ...
},

该查询列出了“帐户”类型中的可用字段及其类型,包括列表LIST类型的类型,例如AccountEvent。

7.3 GraphQL

有一些第三方GUI程序使用内省功能来提供GUI来探索和原型查询,最知名的是graphiql

可以将GraphiQL安装到集群中或运行本地客户端。我正在使用Graphiql-app,它是Graphiql的一个Electron包装。要在Mac上安装,请使用brew:

> brew cask install graphiql

将URL指向我们在本地Docker Swarm模式集群内运行的API,并享受完整的代码补全等特性来编写查询或查看模式:

8.总结

就是这样!在这一章节中,我们增加了使用GraphQL查询我们的account对象的支持。虽然我们的使用非常基础,但它应该让您开始使用GoLog GraphQL。关于GraphQL还有很多需要探索的地方,为了进一步的研究,我推荐官方介绍以及wehavefaces.net

在下一章节中,我们将最终使用Prometheus端点添加对监视的支持。

请帮助传播这个单词!随意使用您最喜爱的社交媒体平台分享这篇博文,下面有一些图标可以帮助您入门。

直到下一次,

埃里克

本文的版权归 ★忆先★ 所有,如需转载请联系作者。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Albert陈凯

2018-06-14 Spring Framework Overview 5.0Spring Framework Overview

1607
来自专栏Play & Scala 技术分享

Play Scala 2.5.x - Play with MongoDB 开发指南

3145
来自专栏一个会写诗的程序员的博客

《Spring Boot开发:从0到1》第11章 Spring Boot应用监控第11章 Spring Boot Actuator与应用监控

Spring Boot的Actuator 将应用的很多信息暴露出来,如容器中的 bean,自动配置时的决策,运行时健康状态, metrics等等。Actuato...

541
来自专栏程序员叨叨叨

听说你们家的NotifyDataSetChanged不起作用了

前几天,公司项目准备上线,就在前一晚,出现了一个BUG:主页界面刷新无效。千钧一发之际,用了一个笨方法,每次刷新的时候重新setAdapter一下算是实现了基本...

662
来自专栏EAWorld

Resteasy ,从学会使用到了解原理

1、背景知识 1.1)了解Rest是什么? 1.2)了解JAX-RS是什么? 1.3)RestEasy简介 2、手把手教你使...

2494
来自专栏Java技术栈

Jodd - Java界的瑞士军刀轻量级工具包!

1702
来自专栏服务端技术杂谈

服务化配置的另一种可能

项目背景 项目是给内部团队用的,也算是业务场景较为复杂的系统,这种系统较于互联网C端产品,用户量不大,QPS峰值不会太高,但业务会比较复杂,业务变动比较频繁。 ...

2463
来自专栏Albert陈凯

Hadoop数据分析平台实战——200Spring+MyBatis+Mysql框架整合离线数据分析平台实战——200Spring+MyBatis+Mysql框架整合

离线数据分析平台实战——200Spring+MyBatis+Mysql框架整合 项目总体介绍 本项目分为三个模块,分别为: 日志收集模块, 数据分析模块以及...

33312
来自专栏小灰灰

报警系统QuickAlarm使用手册

本片将主要说明QuickAlarm该如何使用,以及使用时需要注意事项 1. 基本使用姿势 首先我们不做任何的自定义操作,全部依靠系统默认的实现,我们的使用步骤如...

38016
来自专栏老马寒门IT

Node入门教程(13)第十一章:mocha单元测试+should断言库+istanbul覆盖率测试+art-template

声明: 以下为老马的全栈视频教程的笔记,如果需要了解详情,请直接配合视频学习。视频全部免费,视频地址:https://ke.qq.com/course/2945...

1170

扫码关注云+社区