写点什么

采用 GraphQL 消灭页面数据加工代码

作者:neverwinter
  • 2023-11-10
    上海
  • 本文字数:6876 字

    阅读完需:约 23 分钟

GraphQL 已经是一项成熟的技术了,生产环境可以放心的采用。最新的 spring-boot 已经对 graphql 提供了完整的支持,在 spring-boot 项目中写 graphql 服务端变得非常简单。

GraphQL 消灭了页面数据加工代码

合理的定义 GraphQL,能达到消灭大量数据加工代码的目的。为了应对前端不同场景的查询数据需求,服务端经常要


  1. 为特定场景去定制返回数据格式

  2. 编写按照数据关联关系把一些数据从数据库等持久化设备中读取出来

  3. 把这些数据加工第一步定制的数据格式


这些工作往往是繁琐、无聊、容易出错、毫无成就感的。这些代码往往后期也不好维护。大部分服务端程序员都不想要写这些代码。有些团队里,服务端会只提供基础的标准的数据格式,而是让前端去做这些数据的加工。而这么做往往带来前后端交互过多,降低性能。另外,前端程序员也不喜欢写这些代码。


GraphQL 提供了标准的数据格式定义和查询语言,可以做到服务端只需要提供如何获取基本的数据,客户端提供查询语言,由 GraphQL 框架负责去做数据的查询和加工。从而消灭了数据加工代码,解放了前后端程序员,降低了研发成本。在某些项目中,这种类型的代码可能占比高达 30%,使用 GraphQL 后,几乎可以全部消灭。

GraphQL 的 Schema

GraphQL 的 schema 文件,是前后端的协议,是 IDL,语法非常简单,只需要定义一系列的数据格式。如:


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,比如:


type Query {    admin: User    vip: [User]    usersByName(name: String): [User]}
复制代码


上面我们给 Query 对象定义了三个属性:


  • admin 对应到一个用户对象

  • vip 对应到一个用户数组

  • usersByName 对应到一个用户数组,需要提供用户的名字来获取这组用户


如此,客户端就可以通过发送查询语言来查询需要的数据了。比如:


query {    admin {        id    }}
复制代码


查询了 Query 对象的 admin 属性的 id 属性。返回数据用 json 格式如:


{    "admin": {        "id": "xxx"    }}
复制代码


客户端通过提供不同的 GraphQL,就可以实现不同的查询效果,而服务端代码不需要根据这些查询修改。这就好比用 sql 查询关系型数据库,一旦表结构定义好了,不需要修改数据库代码,只要给出不同的 sql 就可以查询不同的数据。

选取需要的字段

上面的例子,admin 的只需要 id,就只获取了 id 字段。当然我们还可以自由选取别的字段。


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 的 朋友们的名字,如:


query {    admin {        lover {            friends {                name            }        }    }}
复制代码


注意上面这个查询,friends 属性是一个数组,所以对应的 json 大概如下:


{    "admin": {        "lover": {            "friend": [                {"name": "n1"},                {"name": "n2"}            ]        }    }}
复制代码


关联关系的层次是可以一直嵌套下去的,根据需要去查询就可以了。最后一个夸张点的例子:


quer {    admin {        lover {            friends {                lover {                    lover {                        lover {                            name                        }                    }                }            }         }     }}
复制代码

合并查询

如果我要查询 admin,又要查询名字叫张三的人,原本的两次查询,可以被合并成一次,如:


query {    admin { id name }    usersByName("张三") { id name }}
复制代码


前端优化的时候,有时候要合并查询,减少 io 次数,有时候要分开查询,延迟加载,这些都不要服务端去修改代码了。

服务端实现 GraphQL

GraphQL over HTTP+JSON

GraphQL 的 schema 定义了数据格式,但是没有限定具体的物理数据格式,及传输协议。大部分情况下,大家是通过 http 协议交换 json 来实现的。GraphQL 官方给出了通过 http+json 实现时的参考协议。这里不再解释了,详情看 https://graphql.org/learn/serving-over-http/

Java 服务端

GraphQL 有很多服务端实现,我只用过 https://www.graphql-java.com/ 及 Spring 在其上封装的 api。所以只演示一下 java 的用法。其它语言,思想应该都是类似了。graphql-java 定义了两个最主要的组件,一个是 DataFetcher,一个是 DataLoader。前者实现根据一个对象和另一个对象的关系,查询另一个对象的功能。后者实现了查询数据时的性能优化。如果不使用 spring,直接使用 graphql-java,定义这些组件稍微麻烦一些,要手动去写一些类实现特定接口,调用注册接口去注册之类的。我这里就直接拿 spring 来演示了,相当简单。


首先定义数据对象 User,和 User 数据类型对应,如下:


public class User {    private String id;        private String name;        private Integer age;        private User lover;        private List<User> friends;        // 省略 getter setter}
复制代码

@SchemaMapping

@Controllerpublic class UserGQLController {    @SchemaMapping(typeName = "Query", field = "admin")    public User admin() {        }}
复制代码


上面代码中,通过 SchemaMapping,定义了一个类型间的关联关系如何查询。admin()函数的 @SchemaMapping 的 typeName 是 Query,field 是 admin,这说明当需要访问 Query 对象的 admin 属性的时候,调用这个函数就可以获取到数据了。


再比如:


@Controllerpublic class UserGQLController {    @SchemaMapping(typeName = "User", field = "lover")    public User lover() {    }}
复制代码


这里定义了当访问 User 的 lover 属性的时候,调用 lover() 函数去查询。


再看一个例子:


@Controllerpublic class UserGQLController {    @QueryMapping    public List<User> usersByName(@Argument("name") String name) {    }}
复制代码


这里的 @QueryMapping 实际上相当于 @SchemaMapping(typeName = "Query", field="<函数名>") 的缩写形式,在这里就是查询 Query 对象的 usersByName 属性。 这里还演示了用 @Argument 绑定参数。


最后,来一个完整的示例:


@Controllerpublic 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

当客户端做如下查询:


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 改成:


@Controllerpublic class UserGQLController {
@BatchMapping(typeName = "User", field = "friends") public Map<User, List<User>> friends(List<User> users) { // 批量查询,一次性查询出 users 的所有的朋友 // 再组装成 Map 返回 }}
复制代码


这里 @BatchMapping 不仅定义了一个 User.friendsDataFetcher,还定义了一个 MappedBatchLoader 。 Graphql 会自动的将多次查询合并到一起,形成批次,然后把一批的 user 传递给这个函数来查询。分批的规则是可以通过调用 graphql-java 的 api 去调整,比如批次大小等。


graphql-java 内置了内存缓存,在一次客户端服务端交互内,避免重复查询同一个 id 的对象。你也可以自己去实现一些更大粒度的缓存。

GraphQL 联合

实践中,很可能会出现多个 GraphQL 服务器,提供不同的查询功能,比如用户相关、商品相关、博客相关等。对于客户端来说,需要跨这些服务器查询的时候,就需要客户端自己去做数据合并加工等工作了。为了解决这个问题, 开源的 https://www.apollographql.com/ 项目出现了。它可以成为多个 GraphQL 服务器的前置控制器,把多个 GraphQL 服务器的 schema 合并成一个大的 Schema,简化客户端的查询。

关于数据变更

尽管 Graphql 主要是用来做数据查询的,它也提供了数据更新的功能,定义如:


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 不能混用。客户端调用也比较简单,比如:


mutation {    createUser(req: $req)}
复制代码


这里 $req 是一个参数,需要通过客户端指定对应到的数据对象,用 http+json 的时候,最终被映射到请求体的 variables 属性里。


在 spring 里,代码如:


@Controllerpublic 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 对象


@RestControllerpublic 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,少平铺

举个例子:


type User {    id: ID    name: String    age: Int        carPlate: String    carColor: String    carBrand: String}
复制代码


这里 把车辆相关的信息平铺到 User 内,但是看起来这三个属性应该经常一起查询出来的,这样写就很不好。假如以后要做优化,要延迟加载这三个属性,你要写三个 DataFetcher,默认情况下,可能会查询数据库三次,而实际上你期望查询一次数据库就够了。这个时候,你需要在第一次从数据库查询到数据的时候,自己做个缓存,后面的从缓存取数,减少查询数据库,就很麻烦。如果定义成:


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 定义了传递错误信息格式,众多客户端服务端框架也都是按照这个格式来传递错误信息。如果你要打破这个规则,要自己在返回数据中包含错误信息,如:


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 是最优的选择。

总是使用带名字的查询

为了简单起见,我上面的例子都没有指定查询名称,这对可读性不好。实际项目中,建议都加上名字,比如:


query findAdmin {    admin {        id        name    }}
复制代码


上面的 findAdmin 就是一个名字,可以起到提升可读性的效果。

发布于: 3 小时前阅读数: 20
用户头像

neverwinter

关注

还未添加个人签名 2017-10-25 加入

还未添加个人简介

评论

发布
暂无评论
采用GraphQL消灭页面数据加工代码_BFF_neverwinter_InfoQ写作社区