GraphQL已经是一项成熟的技术了,生产环境可以放心的采用。最新的spring-boot已经对graphql提供了完整的支持,在 spring-boot项目中写graphql服务端变得非常简单。
GraphQL消灭了页面数据加工代码
合理的定义GraphQL,能达到消灭大量数据加工代码的目的。为了应对前端不同场景的查询数据需求,服务端经常要
-
为特定场景去定制返回数据格式
-
编写按照数据关联关系把一些数据从数据库等持久化设备中读取出来
-
把这些数据加工第一步定制的数据格式
这些工作往往是繁琐、无聊、容易出错、毫无成就感的。这些代码往往后期也不好维护。大部分服务端程序员都不想要写这些代码。有些团队里,服务端会只提供基础的标准的数据格式,而是让前端去做这些数据的加工。而这么做往往带来前后端交互过多,降低性能。另外,前端程序员也不喜欢写这些代码。
GraphQL提供了标准的数据格式定义和查询语言,可以做到服务端只需要提供如何获取基本的数据,客户端提供查询语言,由GraphQL框架负责去做数据的查询和加工。从而消灭了数据加工代码,解放了前后端程序员,降低了研发成本。在某些项目中,这种类型的代码可能占比高达30%,使用GraphQL后,几乎可以全部消灭。
GraphQL的Schema
GraphQL的schema文件,是前后端的协议,是IDL,语法非常简单,只需要定义一系列的数据格式。如:
vbnet
type User {
id: ID
name: String
age: Int
friends: [User]
lover: User
}
这里定义的User类型有 id name age friends lover 这些属性。只要获取到了一个 User,就可以获取到它的关联的属性。实际上这形成了一个网状的数据结构。这也是为什么叫GraphQL的原因,GraphQL就是 graph query language,即图查询语言。对于上面这个结构,我们如何查询到User呢?答案是通过一个特殊的根对象Query。在GraphQL中,所有查询都从这个特殊的根对象开始。所以我们要定义如何从这个根对象中获取User,比如:
makefile
type Query {
admin: User
vip: [User]
usersByName(name: String): [User]
}
上面我们给Query对象定义了三个属性:
-
admin 对应到一个用户对象
-
vip 对应到一个用户数组
-
usersByName 对应到一个用户数组,需要提供用户的名字来获取这组用户
如此,客户端就可以通过发送查询语言来查询需要的数据了。比如:
bash
query {
admin {
id
}
}
查询了Query对象的admin属性的id属性。返回数据用json格式如:
json
{
"admin": {
"id": "xxx"
}
}
客户端通过提供不同的GraphQL,就可以实现不同的查询效果,而服务端代码不需要根据这些查询修改。这就好比用sql查询关系型数据库,一旦表结构定义好了,不需要修改数据库代码,只要给出不同的sql就可以查询不同的数据。
选取需要的字段
上面的例子,admin的只需要id,就只获取了id字段。当然我们还可以自由选取别的字段。
bash
query {
admin: {id name age}
}
这个例子,会选取admin属性的 id name age 三个字段
关联查询
上面查询admin的时候已经是关联查询了,本质上就是查询Query对象的admin属性,这个admin属性代表了Query对象和User对象之间的一种关联关系。由于admin属性对应到的就是User对象,而User对象有lover属性,所以,我们可以查询admin属性的lover的age,如:
quer {
admin {
lover {
age
}
}
}
还可以查询 admin 的 lover 的 朋友们的名字,如:
erlang
query {
admin {
lover {
friends {
name
}
}
}
}
注意上面这个查询,friends属性是一个数组,所以对应的json大概如下:
json
{
"admin": {
"lover": {
"friend": [
{"name": "n1"},
{"name": "n2"}
]
}
}
}
关联关系的层次是可以一直嵌套下去的,根据需要去查询就可以了。最后一个夸张点的例子:
quer {
admin {
lover {
friends {
lover {
lover {
lover {
name
}
}
}
}
}
}
}
合并查询
如果我要查询admin,又要查询名字叫张三的人,原本的两次查询,可以被合并成一次,如:
bash
query {
admin { id name }
usersByName("张三") { id name }
}
前端优化的时候,有时候要合并查询,减少io次数,有时候要分开查询,延迟加载,这些都不要服务端去修改代码了。
服务端实现GraphQL
GraphQL over HTTP+JSON
GraphQL的schema定义了数据格式,但是没有限定具体的物理数据格式,及传输协议。大部分情况下,大家是通过http协议交换json来实现的。GraphQL官方给出了通过http+json实现时的参考协议。这里不再解释了,详情看 graphql.org/learn/servi... 。
Java服务端
GraphQL有很多服务端实现,我只用过 www.graphql-java.com/ 及 Spring 在其上封装的api。所以只演示一下java的用法。其它语言,思想应该都是类似了。
graphql-java定义了两个最主要的组件,一个是DataFetcher,一个是 DataLoader。前者实现根据一个对象和另一个对象的关系,查询另一个对象的功能。后者实现了查询数据时的性能优化。如果不使用spring,直接使用graphql-java,定义这些组件稍微麻烦一些,要手动去写一些类实现特定接口,调用注册接口去注册之类的。我这里就直接拿spring来演示了,相当简单。
首先定义数据对象User,和User数据类型对应,如下:
arduino
public class User {
private String id;
private String name;
private Integer age;
private User lover;
private List<User> friends;
// 省略 getter setter
}
@SchemaMapping
kotlin
@Controller
public class UserGQLController {
@SchemaMapping(typeName = "Query", field = "admin")
public User admin() {
}
}
上面代码中,通过SchemaMapping,定义了一个类型间的关联关系如何查询。admin()函数的@SchemaMapping的typeName是Query,field是admin,这说明当需要访问Query对象的admin属性的时候,调用这个函数就可以获取到数据了。
再比如:
kotlin
@Controller
public class UserGQLController {
@SchemaMapping(typeName = "User", field = "lover")
public User lover() {
}
}
这里定义了当访问 User的lover属性的时候,调用 lover() 函数去查询。
再看一个例子:
less
@Controller
public class UserGQLController {
@QueryMapping
public List<User> usersByName(@Argument("name") String name) {
}
}
这里的@QueryMapping实际上相当于 @SchemaMapping(typeName = "Query", field="<函数名>") 的缩写形式,在这里就是查询 Query 对象的 usersByName 属性。 这里还演示了用 @Argument 绑定参数。
最后,来一个完整的示例:
less
@Controller
public class UserGQLController {
@QueryMapping
public User admin() {}
@QueryMapping
public List<User> vip() {}
@QueryMapping
public List<User> usersByName(@Argument("name") String name) {}
@SchemaMapping(typeName = "User", field = "friends")
public List<User> friends(User user) {
// 查询 user 的 friends 列表
}
@SchemaMapping(typeName = "User", field = "lover")
public User lover(User user) {
// 查询 user 的 lover
}
}
DataFetcher
上面实际上是通过annotation定义了一些DataFetcher。 DataFetcher负责查询一个对象的一个属性。上面的例子中,我们定义了 Query.admin
Query.vip
Query.usersByName
User.friends
User.lover
,但是没有定义 User.id
User.name
User.age
。graphql-java自动会给所有属性定义一个 PropertyDataFetcher
,这个类的逻辑非常简单,就是从当前的dto中查找和属性名同名的bean property 作为 dto的 属性。 举个例子,当 admin()
函数返回的User
对象里的name
属性值为 "张三" 的时候,如果客户端需要name
属性,那么 User.name
对应的PropertyDataFetcher
就会从User java对象中,通过 getName() 获取 "张三" 值,写入到最终给前端的数据中。
那么,admin() 函数查询 User的时候,可以让 User对象的 friends 和 lover 是 null。这样,只要用户不需要查看admin的 friends 和 lover,friends()和lover()这两个函数及其对应的DataFetcher都不会被调用到。这实际上就实现了按需加载的能力。
BatchMapping
当客户端做如下查询:
bash
query {
vip {
friends {
id
name
firends {
id
name
}
}
}
}
查询所有vip的朋友及朋友的朋友。假设,有10个User,他们互相都是朋友。可以想像到,这个查询结果里有大量的重复查询。第一个层级vip有10个对象,第二层级friends有90,第三层级friends有810个对象。这其中 User.friends 对应的DataFetcher会被重复调用 100次。如果每次都执行一次数据库查询,那肯定会引发性能问题。需要某种机制,合并单个查询成批量查询。这个就是 graphql-java的 DataLoader BatchLoader MappedBatchLoader 要解决的问题。手动定义这些组件能获取最大的灵活性,当然代码更难写。这里还是用spring的封装来演示,非常简单。比如,我们把查询 User.friends
改成:
swift
@Controller
public class UserGQLController {
@BatchMapping(typeName = "User", field = "friends")
public Map<User, List<User>> friends(List<User> users) {
// 批量查询,一次性查询出 users 的所有的朋友
// 再组装成 Map 返回
}
}
这里 @BatchMapping
不仅定义了一个 User.friends
的 DataFetcher
,还定义了一个 MappedBatchLoader
。 Graphql会自动的将多次查询合并到一起,形成批次,然后把一批的user传递给这个函数来查询。分批的规则是可以通过调用 graphql-java 的api去调整,比如批次大小等。
graphql-java内置了内存缓存,在一次客户端服务端交互内,避免重复查询同一个id的对象。你也可以自己去实现一些更大粒度的缓存。
GraphQL联合
实践中,很可能会出现多个GraphQL服务器,提供不同的查询功能,比如用户相关、商品相关、博客相关等。对于客户端来说,需要跨这些服务器查询的时候,就需要客户端自己去做数据合并加工等工作了。为了解决这个问题, 开源的 www.apollographql.com/ 项目出现了。它可以成为多个GraphQL服务器的前置控制器,把多个GraphQL服务器的schema合并成一个大的Schema,简化客户端的查询。
关于数据变更
尽管Graphql主要是用来做数据查询的,它也提供了数据更新的功能,定义如:
perl
type Mutation {
createUser(req: CreateUserReq): String
addFriend(req: AddFriendReq): String
}
input CreateUserReq {
name: String
age: Int
}
input AddFriendReq {
userId: String
friendIds: [String]
}
注意,作为输入参数的类型,用关键字 input
定义。并且 input 和 type 不能混用。
客户端调用也比较简单,比如:
php
mutation {
createUser(req: $req)
}
这里 $req
是一个参数,需要通过客户端指定对应到的数据对象,用http+json的时候,最终被映射到请求体的 variables 属性里。
在spring里,代码如:
less
@Controller
public class UserGQLController {
@MutationMapping
public String createUser(@Argument("req") CreateUserReq req) {}
}
很简单,没什么好说的。
安全
如果你使用http+json来实现graphql,那么之前针对http的安全设施基本都可以重用。需要注意的是,默认graphql通过一个固定url暴露出去的,那些根据url进行访问控制的需要更换成根据类方法来进行控制了。如果你觉得让客户端自由定义查询语句不安全,可以把编辑好的查询语句存放到服务端,把它们映射到特定url上,或者参数上,这样客户端只能通过url或者参数使用特定的查询语句。我还没有这么干过,但我想可以有以下办法:
-
写个filter过滤一批url,根据url取相应的预定义的查询语句,修改请求体,再服务端传递给graphql的controller上
-
自己写个Controller,获取预定义的查询语句后,直接调用graphql-java的 GraphQL 对象
typescript
@RestController
public class GraphqlGatewayController {
private final GraphQL graphql;
private final ShopBatchLoader shopBatchLoader;
public GraphqlGatewayController(
GraphQL graphql,
ShopBatchLoader shopBatchLoader
) {
this.graphql = graphql;
this.shopBatchLoader = shopBatchLoader;
}
@RequestMapping(value = "/graphql", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
@SuppressWarnings("unchecked")
public Object executeOperation(@RequestBody Map<String, Object> body) {
String query = (String) body.get("query");
Map<String, Object> variables = (Map<String, Object>) body.get("variables");
if (variables == null) {
variables = new LinkedHashMap<>();
}
DataLoaderRegistry registry = new DataLoaderRegistry();
registry.register("shop", DataLoaderFactory.newDataLoader(shopBatchLoader));
ExecutionResult executionResult = graphql.execute(
ExecutionInput.newExecutionInput().query(query)
.variables(variables)
.dataLoaderRegistry(registry)
.build()
);
return executionResult.toSpecification();
}
}
GraphQL vs http+json
首先必须强调一下,使用了GraphQL,不代表就不能使用http+json了,这两者完全是不冲突的技术。因此实际上不用特别纠结为什么用Graphql而不用http+json,因为你可以即用GraphQL,又用http+json。实际上,我们也确实这么干的。
GraphQL有一个schema,采用的是协议优先的开发方式,而不是一般开发http+json的代码优先的方式。也就是说,我们会先定义一个服务端和客户端的数据协议,然后再用代码去实现它。而一般的http+json都是没有这个协议,直接代码即协议。GraphQL的这一点和Grpc、Thrift等是类似的。我本人比较偏向于协议优先的开发方式。现在java开发中,有很多用java代码作为协议,但是实际上java真的是一种拙劣的协议描述语言。
对于查询数据的格式不固定,不方便用schema描述的时候,直接用 http+json 则更为方便。
最佳实践
多用type,少平铺
举个例子:
vbnet
type User {
id: ID
name: String
age: Int
carPlate: String
carColor: String
carBrand: String
}
这里 把车辆相关的信息平铺到 User内,但是看起来这三个属性应该经常一起查询出来的,这样写就很不好。假如以后要做优化,要延迟加载这三个属性,你要写三个 DataFetcher,默认情况下,可能会查询数据库三次,而实际上你期望查询一次数据库就够了。这个时候,你需要在第一次从数据库查询到数据的时候,自己做个缓存,后面的从缓存取数,减少查询数据库,就很麻烦。如果定义成:
vbnet
type User {
id: ID
name: String
age: Int
car: Car
}
type Car {
plate: String
color: String
brand: String
}
如果你期望查询User的时候,提前就把Car查询出来,只要创建User对象的时候,把 car 属性也设置有值,默认的 PropertyDataFetcher就可以完成映射。如果你希望按需加载,就为 User.car
单独写一个DataFetcher就可以了。
重用GraphQL的错误信息
GraphQL定义了传递错误信息格式,众多客户端服务端框架也都是按照这个格式来传递错误信息。如果你要打破这个规则,要自己在返回数据中包含错误信息,如:
vbnet
type Query {
admin: AdminResult
}
type AdminResult {
errorCode: Int
errorMessage: String
data: User
}
type User {
id: ID
name: String
}
这会让schema很难看,而且你要修改服务端和客户端去遵循这个规则。顺便说一下,即使是用http+json,也不应该在respon body中放error信息。
总是用BatchMapping
批量查询在查询量少的时候,不会有多外额外的开销,而在查询量大的时候,有着比一条一条查询高得多的性能。在一个复杂的项目中,有时候你是不能确定某个关系是否会被大量查询的,因此,总是用 BatchMapping 是最优的选择。
总是使用带名字的查询
为了简单起见,我上面的例子都没有指定查询名称,这对可读性不好。实际项目中,建议都加上名字,比如:
bash
query findAdmin {
admin {
id
name
}
}
上面的 findAdmin 就是一个名字,可以起到提升可读性的效果。