遇见 GraqphQL

已经一个多月没有更新了,因为公司开了新项目,并且由于业务的特殊性,上线期限比较紧,又碰上国庆长假,所以时间非常紧张。这段时间一直都是加班加点,偶尔周末也会加一天班,所以整个人都比较累,回家了也没什么精神写博客了。正好到这周终于没有什么杂事来打扰,有空总结下这段时间的经验和收获。

这次就主要说一下项目中用到的新的 API 请求框架 GrqphQL 。GrqphQL 是由 facebook 开源的新一代 API 请求框架,具体的介绍和与 REST 的差别大家还是 Google 吧,我也不想 copy and paste,不过我会把具体使用过程中遇到的问题和体会写在这里,供大家参考。

使用准备

GrqphQL 在 Android 和所有的移动端、前端都主要是由两部分构成的:数据结构和请求语句,对应到项目中就是 schema.json 和 xxx.graphql 文件。

其中 schema.json 可以通过 apollo-codegen 这个工具请求自动生成,安装这个工具需要先安装 nodejs 环境,然后执行 npm install -g apollo-codegen 就可以安装了。然后在命令行下执行 apollo-codegen download-schema {url}/graphql --output {path}/schema.json 就能得到 schema.json 这个文件了。

然后就是 xxx.graphql 文件了。写请求语句需要对照后端 API 文档,所以还需要一个 GraphQL API 调试工具,github 上有一个非常好用的工具 GrqphQL-IDE,不仅可以查看所有接口的参数和类型,可以实时更新,还可以像一个传统的查询语言模拟 GraphQL 请求,看到实际的返回结果,同时也能够配置多环境,也可以导入数据文件,使用别人的 example,作用基本与 Postman 类似,mac 可以直接下载使用。

还有一个官方的 IDE 项目 GraphIql,是一个网页版的 GraphQL 调试工具,但是仍然需要在本地安装依赖组件,所以我觉得上面的 GraphQL 更加方便,不需要安装任何依赖,开箱即用。

GrqphQL 请求分为两类,query 和 mutation。query 对应于传统的 GET 读操作,mutation 对应于 PSOT,PUT 等写操作,使用上的区别在下面会讲到。

另外在真机上测试的时候 GraphQL IDE 就派不上用场了,所以还是抓包看看实际传输的数据吧,这样即使不依托于 GrqphQL 在 Android 中的依赖库,也能自己使用 REST 框架构建和发起 GraphQL 请求,当然这是我曾经无奈之下的办法,并不推荐这种方式。

GraphQL 其实也有 IDEA IDE 插件,地址是 https://github.com/apollographql/intellij-graphq, 需要自己编译安装,目前功能较弱,没什么存在感。

框架配置

然后就是在 Android 中实际使用了。 GrqphQL 有专用于 Android 的 github 项目,地址是 apollo-android ,可用于 Android 的依赖包都在这个项目中,不过经过我这段时间的使用,感觉对于 iOS 和 Web 端来说,Android 的实现还不是非常到位,其中有不少坑,我也会一一列出来供大家参考。

首先在 project 级别下的 build.gradle 中添加如下部分:

1
2
3
4
5
6
7
8
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.apollographql.apollo:apollo-gradle-plugin:x.y.z'
}
}

截止目前该库的版本为 0.4.2,也就是在几天之前更新的。本来在 0.4.2 之前,还需要在 app 级别下的 build.gradle 中添加以下依赖:

1
2
compile "com.apollographql.apollo:apollo-runtime:$apolloReleaseVersion" 
compile "com.apollographql.apollo:apollo-android-support:$apolloReleaseVersion"

不过看 github 的介绍已经没有这段了,经过实验去掉之后也不会有什么影响了。不过如果你要添加 rxJava 支持或 apollo 族中其他组件,还是需要另行添加的。

然后需要在 app 下 build.gradle 中加入 apollo 的 plugin:

1
apply plugin: 'com.apollographql.android'

另外需要注意的是,在 app 下的 build.gradle 中,android.application 的 plugin 应该是在 GraphQL plugin 之前的,如果你还使用 kotlin,那么就应该是这样的:

1
2
3
4
apply plugin: 'com.android.application'
apply plugin: 'com.apollographql.android'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

也就是 apollo 的 plugin 是在 application 之后,kotlin 之前的。

如果有需要还可以自定义一些配置,比如我目前用到的:

1
2
3
4
5
apollo {
generateModelBuilder = true
schemaFilePath = "/Users/alpha/Projects/graphql-example/app/src/main/java/com/alphagao/graphql-example/compontents/graphql/schema.json"
outputPackageName = "com.alphagao.graphql-example.compontents.graphql.api"
}

其中 generateModelBuilder 用于对 apollo 中所生成的所有 pojo 类增加 builder 方法,以生成新的对象,apollo 默认所有的数据对象都是只能读不能写的,通过查看生成的 Java 代码就会发现所有的 pojo 类的属性都是 final 类型的,非常不方便临时修改和保存状态,而且即使开启了 这个配置,要改变一个对象的值也是非常麻烦的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val feed = FeedsQuery.Feed
.builder()
.__typename(item.__typename())
.child_count(item.child_count())
.comment_count(item.comment_count())
.created_at(item.created_at())
.gather(item.gather())
.id(item.id())
.like_count(if (item.liked()!!) item.like_count()?.minus(1) else item.like_count()?.plus(1))
.liked(!item.liked()!!)
.photo_count(item.photo_count())
.photos(item.photos())
.published_at(item.published_at())
.sent_type(item.sent_type())
.teacher(item.teacher())
.title(item.title())
.content(item.content())
.build()
feedList[position] = feed
item = feed

这里我只是想要点个赞,改变 Liked 的值,就需要将整个 item 对象重新构建一遍,你说麻烦不麻烦。而在 iOS 中就没有这个问题。
既然说到了这里,还有一个坑不得不提,apollo 中的所有数据和列表都是使用不可变的实现,也就是 UnmodifiableRandomAccessList 这个类,不支持添加和删除,所以也是比较坑,好在在实际使用中可以通过构造一个可变列表对象,然后把原有的数据添加到可变列表中,最后用可变列表把原有的列表对象替换掉就行了。

schemaFilePath 这个就是自定义 schema.json 的位置的。默认这个文件需要放置到 app/src/main/graphql/ 路径下,同时这个路径也是默认的 xxx.graphql 文件的路径,所有的查询语句文件都放在这里。但其实这个路径好像只支持绝对路径,这样其实有点坑了,因为绝对路径每个人都不一样的,如果有 jenkins 自动打包环境,那么就更加不适用了,所以最好还是不要设置这个选项为好。

outputPackageName 在设置了 schemaFilePath 的情况下这个选项是必须设置的,但是也可以单独设置。当然最好设置一下,如果是默认的生成路径,会导致非基本类型无法 import,这样需要传入非基本类型的请求都会无法使用了。改变默认路径可以避免这一情况。

初始化

由于 GraphQL 的 Android 库中都是以 apollo 自居的,因此下面也换成 apollo 来替代 GrqphQL。

apollo 有自己 HTTP 请求 Client,也就是 ApolloClient,这个类需要在程序入口处进行初始化,一般可以放在 Applicaiton 的 onCreate() 中,当然也可以通过单例模式在其它类文件中初始化。这里我使用 kotlin 在 Application 中初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class App : Application(){

//静态变量
companion object {
lateinit var instance: App
private set

lateinit var apolloClient: ApolloClient
}

override fun onCreate() {
super.onCreate()
instance = this
//...其他操作
initApollo()
}

private fun initApollo() {
apolloClient = ApolloClient.builder()
.serverUrl(Config.BASE_URL)
.okHttpClient(OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HostInterceptor())
.build())
.build()
}
}

需要注意的是 serverUrl 必须以 “/” 结尾,通常来说是这种格式:“http://{url}/graphql/”。

query

现在我们就可以写一个 query 语句发起一个请求了,一个最简单的 query.graphql 长这样:

1
2
3
4
5
6
query ContactQuery{
contact{
information
type
}
}

其中 ContractQuery 是生成的对应的类文件的类名,如果后缀没有 Query,框架会自动帮你添加,当然也可以配置不添加这个后缀,通过在框架配置一节中提到的 apollo 模块下加入 useSemanticNaming = false 即可。mutation 语句也是同理。

其中 contract 对应于 schema 中的一个操作,大小写是固定的,可以通过 GraphQL IDE 来确定。contract 内部的属性表示返回类型的属性,这些属性只要是属于这个返回对象的,都可以随意添加,这也是 GraphQL 的特性之一。

以上语句的返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"data": {
"contact": [{
"__typename": "ContactType",
"information": "138xxxxxxxx",
"type": "callphone"
}, {
"__typename": "ContactType",
"information": "xxxxxxxxxxxx",
"type": "wechat"
}]
}
}

其中 __typename 是每个对象都会有的属性,值通常是 类名+Type

然后再写一个带有传入参数的 query 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
query FeedsQuery($clazz_ids:[ID!],$limit:Int!,$offset:Int!,$teacher_id:ID!){
feeds(clazz_ids: $clazz_ids, limit:$limit, offset: $offset) {
id
teacher {
id
name
avatar
}
title
content
gather{
gathered_children{
avatar
name
}
not_gathered_children{
avatar
name
}
}
liked(teacher_id:$teacher_id)
photos(photo_count:4,uploaded:true) {
rotation
url
}
}
}

这个看起来就略微复杂一点了。最外层括号中是所有需要的参数的定义,[] 表示需要传入一个数组,! 表示非空类型,然后在 feeds 操作中引用这些参数,这里所有的参数名都需要与 GraphQL IDE 中的参数的大小写保持一致。如果 feeds 里面某个对象还需要参数,直接引用最外层的定义即可,不需要 feeds 操作向内传递。这些所有的参数都可以直接写死,这样也就不需要定义参数了,比如例子中的 photo_count:4。每个返回对象都必须跟一对大括号,即时你不需要任何属性。另外仅通过查询语句是没办法知道返回的是一个对象还是一个数组列表的,这些都是由服务端控制的,比如例子中 teacher 是一个对象,而 photos 是一个对象列表。不过好像看名字可以大概分辨出哦,毕竟 photos 带着个 s 嘛。

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
{
"data": {
"feeds": [{
"__typename": "FeedType",
"id": "ba20784f-fee2-4ea9-bec1-a6a93c5f59a5",
"teacher": {
"__typename": "UserType",
"id": "721bd480-3d2c-4b38-873d-7550853d8b93",
"name": "Kobe",
"avatar": "http://8adf0d6806f9943afdff525b6651a2a4.jpg"
},
"title": "as",
"content": null,
"gather": {
"__typename": "GatherType",
"gathered_children": [],
"not_gathered_children": [{
"__typename": "ChildType",
"avatar": "http://dsdsaf-fsafa-fag-ag-asgsaf.jpg",
"name": "Jack"
}, {
"__typename": "ChildType",
"avatar": null,
"name": "Jenny"
}]
},
"liked": true,
"photos": [{
"__typename": "PhotoType",
"rotation": 0,
"url": "http://f2161f3b-7816-4c42-bdd9-d70b677dc383.jpg"
}]
}, {
"__typename": "FeedType",
"id": "2e973021-39f0-4178-a3be-c5f3474b99a0",
"teacher": {
"__typename": "UserType",
"id": "67982f14-2951-4533-a61b-c047b5e8d2d2",
"name": "James",
"avatar": "976c6d4f93cbdac4462e8a7f5cdd68da.jpg"
},
"title": "富贵花",
"content": "",
"gather": {
"__typename": "GatherType",
"gathered_children": [],
"not_gathered_children": [{
"__typename": "ChildType",
"avatar": "http://dsdsaf-fsafa-fag-ag-asgsaf.jpg",
"name": "Jack"
},{
"__typename": "ChildType",
"avatar": null,
"name": "Jenny"
}]
},
"liked": false,
"photos": [{
"__typename": "PhotoType",
"rotation": 0,
"url": "http://9f511880-ccec-4617-af59-cf589287a709.jpg"
}, {
"__typename": "PhotoType",
"rotation": 0,
"url": "http://cb5d4896-a70d-48ef-8a95-47fc0f4d67fb.jpg"
}]
}
}
}

写完这个查询语句,只需要把这个文件放在合适的地方,目前好像只支持默认路径,也就是 app/src/main/graphql/...,最好是在 graphql 之后添加合适的包名路径。

现在只要 make 一下 app,就可以看到生成的类文件了,如果报 nodejs 错误,说明你的查询语句中有不正确的地方,这里的报错也没有 iOS 中来的精确,只是说明错误,但并没有指出具体错在哪里,只有通过前面提到的 IDEA 插件可以检测与 schema 文件不符合地方。所以还是希望 apollo-android 的库能够快速迭代,早日达到 iOS 库的体验。

mutation

mutation 与 query 在大前端基本没有太大差别,语句结构也是类似的,只是可能会传入非基本类型,需要注意修改默认的生成路径即可。

请求

下面就来看如何进行请求,就以上面的 FeedsQuery 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun getFeeds(class_ids: List<String>, teacher_id: String, limit: Int, offset: Int, listener: DataListener<FeedsQuery.Data?>) {
val query = FeedsQuery
.builder()
.clazz_ids(class_ids)
.limit(limit)
.teacher_id(teacher_id)
.offset(offset)
.build()
App.apolloClient.query(query).enqueue(object : ApolloCall.Callback<FeedsQuery.Data?>() {
override fun onFailure(e: ApolloException) {
listener.onComplete(null)
//ToastUtils.toast(e.toString())
}

override fun onResponse(response: Response<FeedsQuery.Data?>) {
listener.onComplete(response.data())
}
})
}

这个方法可以随意定义在某个类中。带有参数的查询都会按照流式 API 的构建者模式调用,然后生成 Query 对象,使用 apolloClient.query() 方法即可发起请求。这里同样支持同步请求(execute)和异步请求(enqueue),不过我想没人会使用同步请求的吧。而且看 apollo-android 的 release note,从 0.4.2 版本开始,已经不支持同步请求了。

如果是 mutation,通过 build() 方法会得到一个 mutation 对象,然后调用 apolloClient.mutate() 方法即可,其他与 query 都是相同的。

总结

目前半个多月使用下来,感觉 GraphQL 虽然是新一代请求框架,潜力确实很大,只是目前还在发展中,尚未稳定,尤其是 Android 中还存在很多不足之处,相比 iOS 下的使用体验还是差了很多,并没有比通过 REST 方式来的更容易。如果你觉得 OK,甚至可以通过 REST 框架来构造 GraphQL 请求,我就干过这事,甚至觉得体验要比单纯 GrqphQL 要更好。。。

当然好处也很多,如果后台 API 接口写好了,只需要通过 GrqphQL-IDE 就可以自己任意定制参数来发起请求,也可以直接使用 iOS 的查询文件,不需要再写一遍。

然后 GraphQL 隐藏了具体的请求细节,通过 ApolloClient 可以直接发起请求,基本不用怎么关注 Http 协议的部分。

通过 GraphQL 可以合并很多细小的接口,只需要在请求中加入某个参数,一次请求就能拿到 REST 好几次才能拿到的完整的数据结构。

目前的坏处也是目前我所体验到的,不过由于当前的使用仍然处在最简单的层面,很多高级特性并没有用到,例如 fragment,repositroy 等,所以并不能够客观的评价这个框架,但事实是越来越多的公司都开始使用 GraphQL,未来会有更多的公司加入,逐渐组成一个完整的生态圈,也会促使各个语言中的实现越来越完善,所以早日拥抱变化并不是一件坏事。