在实践微服务系列博客的这一篇中,我们将看看如何使用GraphQL将Account对象提供给我们的客户端。
完成的源代码可以从GitHub克隆:
> git clone https://github.com/callistaenterprise/goblog.git
> git checkout P14
系列博客的这一篇不会引入任何新的服务,它只会将新的/graphql
POST端点添加到“accountservice”,该端点将按照我们将在Go代码中定义的graphql模式来响应请求。
GraphQL由Facebook在内部开发并于2015年公开发布。它为RESTful和其他架构提供了用于从服务器向客户端提供数据的另一种查询语言。也许最独特的特点是GraphQL允许客户端定义如何构造请求的数据,而不是让服务器决定。这意味着客户端可以准确地获取所需的数据,从而缓解这个经典问题,为手头用例获取到的数据太多或太少。
我建议你深入了解官方文档以获取更详细的解释。
我们来快速浏览一下使用关键概念设置GraphQL模式(schema),例如类型(types),字段(fields),解析函数(resolve functions),根查询(root queries)和结果模式(resulting schema)。
模式定义了可以在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...
解析函数是我掌握的最难的部分,在查看真正的帐户查询的解析函数时,我们会稍微看到更多的解析代码。
为了让客户能够获取账户对象,我们需要创建一个由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上的查询。
带有字段和类型的最终模式由我来说可以像这样表示:
如果我们想要一个返回账户列表的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”搜索或类似的查询。
那么,现在我们已经将模式放在一起了,我们如何将底层数据模型绑定到在“解析器”参数中声明的解析函数?(它作为参数传递给设置所有这些东西的函数)
一件美妙的事情是我们可以向解析函数传递“鸭子类型”的
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”函数相同的代码来获取帐户对象之外,没有新的东西。
使用的解析器实现只是在调用代码提供的内容。在单元测试中:
func TestFetchAccount(t *testing.T) {
initQL(&TestGraphQLResolvers{}) // Test implementation passed.
....
当从主函数启动时 ( 即运行独立或在Docker容器中启动时),则会调用这一行:
initQL(&LiveGraphQLResolvers{})
到目前为止,我们只是奠定了基础。毕竟,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查询可以做很多事情,可以在这里查看分片,参数,变量等。
我们如何断言我们的模式实际上是以一种有效的方式建立的并且我们的查询会好好工作?单元测试拯救世界!
所有的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"}}}}`)
})
})
})
}
上面的测试结构使得编写查询并根据模式测试它们非常简单。实际的输出当然会有所不同,具体取决于您的TestResolver使用的测试数据以及它们如何处理传递给它们的参数。
除非我们能够为我们的服务的消费者提供功能,否则所有这些GraphQL都是无用的。现在是将GraphQL功能连接到HTTP路由器的时候了!
我们来看看/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 ...
(关于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中的所有条目!
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。
有一些第三方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端点添加对监视的支持。
请帮助传播这个单词!随意使用您最喜爱的社交媒体平台分享这篇博文,下面有一些图标可以帮助您入门。
直到下一次,
埃里克