2015年7月,Facebook 发GraphQL布并开源了GraphQL,GraphQL作为负责前后端交互的协议,很好的解决了单一的后端服务在面对多前端(Android,iOS,mobile web,PC web)的场景下,能够针对同一场景提供不同数据以满足客户端应用展示的需要。
GraphQL是一种类json的语言,有自己的一套语法来请求获得客户端指定的数据或者进行增删改操作,而服务器端则根据客户端的请求封装数据,以json格式返回给前端。GraphQL的语法可以参考http://graphql.org/learn。
我们假设现在有一个电商服务需要同时有iOS,Android和PC web三种客户端,该电商支持多种分类的商品的线上交易。作为该电商的用户可以在任意一种客户端上根据不同的分类浏览商品列表,查看商品详情,选择商品将其放入购物车并下单、购买。购买成功后,商品通过快递送到用户下单时填写的地址去。
我们将通过框架graphql-java来实现基于GraphQL的BFF service以应对三种客户端的数据请求。
抽象出合理的数据结构
GraphQL需要服务器端预先定义出一系列数据结构,而客户端则根据定义的数据结构根据业务展示需求选择性的查询所需要的字段。因此在使用GraphQL时,第一步需要根据业务场景抽象出合理的数据结构,然后将这些数据结构映射为GraphQL schema供客户端查询使用。
这里我们可以使用Domain-driven Design的方法针对用户场景对数据建模,并从数据中选择出用户需要了解的数据,隐藏用户不应该知道的数据。我们可以得到以下数据结构:
class Category {
private String id;
private String name;
private List<Product> product;
}
class Product {
private String id;
private String name;
private String description;
private String thumbnail;
private List<Sku> skus;
}
class Sku {
private String id;
private List<OptionPair> options;
private Integer stock;
private BigDecimal price;
}
class OptionPair {
private String key;
private String value;
}
class Order {
private String id;
private String userName;
private String userMobile;
private String address;
private OrderStatus status;
private BigDecimal price;
private List<OrderLine> orderLines;
private Date createTime;
private Date purchaseTime;
private Date finishTime;
}
class OrderLine {
private String skuId;
private String name;
private Integer quantity;
private BigDecimal price;
}
其中数据结构中含有id的entity可以当作Aggregate Root.客户端通过GraphQL查询数据的入口可以从上面列出的entity开始查询。
使用graphql-java实现服务端
定义基本类型
在构建GraphQL schema时,我们需要使用builtin或自建的基本数据类型将数据结构的fields类型转化为GraphQL支持的类型。所以我们要先了解graphql-java的builtin类型并学会如何自定义类型。
graphql-java中的ScalarType
在graphql-java中,除了GraphQL文档中说明的最基本的类型 GraphQLInt, GraphQLFloat, GraphQLString, GraphQLBoolean, GraphQLID之外,还包含了GraphQLLong, GraphQLBigInteger, GraphQLBigDecimal, GraphQLByte, GraphQLShort和GraphQLChar方便开发者使用。
其中需要注意的是,当对field选用了GraphQLID时,只会接受String和Integer类型的值并将其转换为String传递出去, 而通常数据库默认定义的id是Long,如果用GraphQLID的话可能会出错。**
graphql-java中,也可以自定义ScalarType比如定义GraphQLDate并将其serialized, deserialized为timestamp:
public static final GraphQLScalarType GraphQLDate = new GraphQLScalarType("Date", "Built-in Date as timestamp", new Coercing() {
@Override
public Long serialize(Object input) {
if (input instanceof Date) {
return ((Date) input).getTime();
}
return null;
}
@Override
public Date parseValue(Object input) {
if (input instanceof Long) {
return new Date((Long) input);
}
return null;
}
@Override
public Date parseLiteral(Object input) {
if (input instanceof IntValue) {
return new Date(((IntValue) input).getValue().longValue());
}
return null;
}
});
GraphQLEnumType
顾名思义,GraphQLEnumType就是我们数据结构中的enum类型的映射。创建GraphQLEnumType可以使用函数newEnum
,比如OrderStatus:
private GraphQLEnumType orderStatusEnum = newEnum()
.name("OrderStatus")
.description("order status")
.value("OPEN", OrderStatus.OPEN, "unpaid order")
.value("CLOSED", OrderStatus.CLOSED, "closed order")
.value("CANCELLED", OrderStatus.CANCELLED, "cancelled order")
.value("FULFILLED", OrderStatus.FULFILLED, "finished order")
.build();
函数value
声明:
public Builder value(String name)
public Builder value(String name, Object value)
public Builder value(String name, Object value, String description)
public Builder value(String name, Object value, String description, String deprecationReason)
当只传name时,name就为value。
GraphQLObjectType
现在我们可以把我们的数据结构定义为GraphQLObjectType了。定义在GraphQLObjectType里的每一个field都可以被前端得到,所以不应该在这里定义不希望被前端获取的字段,仅以Order为例
GraphQLObjectType orderLineType = newObject()
.name("OrderLine")
.field(field -> field.type(GraphQLID).name("productId"))
.field(field -> field.type(GraphQLID).name("skuId"))
.field(field -> field.type(GraphQLString).name("productName"))
.field(field -> field.type(GraphQLString).name("skuName"))
.field(field -> field.type(GraphQLInt).name("quantity"))
.field(field -> field.type(GraphQLBigDecimal).name("price"))
.build();
GraphQLObjectType orderType = newObject()
.name("Order")
.description("order")
.field(field -> field.type(GraphQLID).name("id"))
.field(field -> field.type(GraphQLString).name("userName"))
.field(field -> field.type(GraphQLString).name("userMobile"))
.field(field -> field.type(GraphQLString).name("address"))
.field(field -> field.type(orderStatusEnum).name("status"))
.field(field -> field.type(new GraphQLList(orderLineType)).name("orderLines"))
.field(field -> field.type(GraphQLDate).name("purchaseTime"))
.field(field -> field.type(GraphQLDate).name("finishTime"))
.field(field -> field.type(GraphQLDate).name("timeCreated"))
.build();
如果GraphQLObjectType的field name和entity的field类型一致的话,graphql-java会自动做mapping。
查询
GraphQL生成的schema是一张图,为了客户端的查询,我们需要提供一个入口。
带参数的查询
通常我们会创建一个用于查询的跟节点,客户端所有使用GraphQL进行查询的起始位置就在跟节点
public GraphQLObjectType getQueryType() {
return newObject()
.name("QueryType")
.field(field -> field.type(orderType).name("order")
.argument(argument -> argument.name("id").type(GraphQLID))
.dataFetcher(dynamicDataFetcher::orderFetcher))
.build();
这里我们在QueryType这个object中声明了一个类型为orderType的field叫order,获得order需要argument id,同时声明了order的data fetcher。
public Order orderFetcher(DataFetchingEnvironment env) {
String id = env.getArgument("id");
return getOrder(id);
}
orderFetcher
接收一个DataFetchingEnvironment类型的参数,其中可以使用该参数的getArgument
方法得到对应的传入参数,也可以使用getSource
的到调用data fetcher当前层的数据结构 比如product:
GraphQLObjectType productType = newObject()
.name("Product")
.description("product")
.field(field -> field.type(GraphQLID).name("id"))
.field(field -> field.type(GraphQLID).name("categoryId"))
.field(field -> field.type(new GraphQLTypeReference("category")).name("category")
.dataFetcher(productDataFetcher::categoryDataFetcher))
...
...
.build();
public Category categoryDataFetcher(DataFetchingEnvironment env) {
Product product = (Product)env.getSource()).getCategoryId();
return getCategory(product.getCategoryId());
}
这里,我们通过env.getSource()
方法拿到了product的数据结构,并根据已有的categoryId去查找category。
注意productType的定义,我们在同时提供了categoryId和category两个field,是为了避免在用户需要得到categoryId的时候在做一次data fetcher的操作。同时,为了避免循环引用,我们使用了GraphQLTypeReference定义category的类型。**
GraphQLNonNull 和 default value
如果我们希望客户端必须要传递某个参数来进行查询时,我们可以使用GraphQLNonNull来标示这个argument,我们可以把订单的查询改为
public GraphQLObjectType getQueryType() {
return newObject()
.name("QueryType")
.field(field -> field.type(new GraphQLNonNull(orderType)).name("order")
.argument(argument -> argument.name("id").type(GraphQLID))
.dataFetcher(dynamicDataFetcher::orderFetcher))
.build();
同样的,如果要给参数提供一个默认值的话,可以使用defaultValue
,如订单的分页查询
.field(field -> field.type(new GraphQLNonNull(new GraphQLList(orderType))).name("orders")
.argument(argument -> argument.name("pageSize")
.type(GraphQLInt).defaultValue(20))
.argument(argument -> argument.name("pageIndex")
.type(GraphQLInt).defaultValue(0))
.dataFetcher(dynamicDataFetcher::ordersFetcher))
Mutation
GraphQL同时支持写的操作,和查询一样,我们可以定义一个用于写数据的跟节点,在定义的data fetcher视线里进行数据的修改,并返回需要的属性。我们可以使用GraphQLObjectType定义更为复杂的传入参数:
private static final GraphQLInputObjectType inputOrderLineType = newInputObject()
.name("InputOrderLineType")
.field(field -> field.name("productId").type(GraphQLID))
.field(field -> field.name("skuId").type(GraphQLID))
.field(field -> field.name("quantity").type(GraphQLInt))
.field(field -> field.name("price").type(GraphQLBigDecimal))
.build();
private static final GraphQLInputObjectType inputOrderType = newInputObject()
.name("InputOrderType")
.field(field -> field.name("storeId").type(GraphQLID))
.field(field -> field.name("orderLines").type(new GraphQLList(inputOrderLineType)))
.build();
要注意的是,当我们在data fetcher里得到GraphQLInputObjectType的参数的时候,得到的是一个类型为LinkedHashMap的数据。**
提供GraphQL API
API层的代码如下
@Component
@Path("graphql")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class GraphQlApi {
private static final Logger LOG = LoggerFactory.getLogger(GraphQlApi.class);
@Autowired
private QueryType queryType;
@Autowired
private MutationType mutationType;
private GraphQL getGraphQl() {
return new GraphQL(getSchema());
}
private GraphQLSchema getSchema() {
return GraphQLSchema.newSchema()
.query(queryType.getQueryType())
.mutation(mutationType.getMutationType())
.build();
}
@POST
public Response executeOperation(Map body, @Context ContainerRequestContext request) {
String query = (String) body.get("query");
Map<String, Object> variables = (Map<String, Object>) body.get("variables");
ExecutionResult executionResult = getGraphQl().execute(query, request, variables == null ? Maps.newHashMap() : variables);
Map<String, Object> result = new LinkedHashMap<>();
if (executionResult.getErrors().size() > 0) {
LOG.warn("GraphQL command execute error, command: {} cause: {}", body, executionResult.getErrors());
result.put("errors", executionResult.getErrors());
}
result.put("data", executionResult.getData());
return Response.ok().entity(result).build();
}
execute
方法接收三个参数,其中第二个参数为context,我们将request直接传递了进去,用于之后的权限验证。
权限验证
当用户访问一些敏感数据的时候,我们可能要对用户的权限进行验证,这时我们可以在data fetcher的实现里利用上面调用execute
时传递的context进行验证了:
public UserInfo userInfoFetcher(DataFetchingEnvironment env) {
final ContainerRequestContext requestContext = (ContainerRequestContext) env.getContext();
// Using requestContext check permission here.
...
}
ErrorHandler
在执行GraphQL命令时,会进行GraphQL Schema和GraphQL命令的语法检查,并且会handler所有data fetcher的异常,最后转为GraphQLError的list放进ExecutionResult并返回给结果。GraphQLError接口声明如下:
public interface GraphQLError {
String getMessage();
List<SourceLocation> getLocations();
ErrorType getErrorType();
}
很多时候GraphQLError其实并不能满足实际情况的需要。所以需要做一些转换已满足使用需求。现提供一种思路如下:
private List<Json> customError(ExecutionResult executionResult) {
return executionResult.getErrors()
.stream()
.map(this::handleError)
.map(this::toJson)
.collect(Collectors.toList());
}
private Throwable handleError(GraphQLError error) {
switch (error.getErrorType()) {
case DataFetchingException:
return ((ExceptionWhileDataFetching) error).getException();
case ValidationError:
case InvalidSyntax:
return new Exception(error.getMessage());
default:
return new UnknownException();
}
}
private Json toJson(Throwable throwable) {
final Json json = Json.read(json(throwable));
json.delAt("stackTrace");
json.delAt("localizedMessage");
json.delAt("cause");
return json;
}
GraphQL的提速
GraphQL的协议允许在调用query
命令时用并行查询,而mutation
时则禁止使用并行操作,如要实现query
的并行,可以如下配置:
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
2, /* core pool size 2 thread */
2, /* max pool size 2 thread */
30, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadPoolExecutor.CallerRunsPolicy());
GraphQL graphQL = GraphQL.newObject(StarWarsSchema.starWarsSchema)
.queryExecutionStrategy(new ExecutorServiceExecutionStrategy(threadPoolExecutor))
.mutationExecutionStrategy(new SimpleExecutionStrategy())
.build();
而 data fetcher的cache等操作,则需要开发者自行完成。
提供GraphQL的文档
运用argument
和dataFetcher
,我们可以定义出一个庞大的数据图,而前端则根据该数据图自行定义查询。可以使用工具graphdoc
来生成GraphQL的文档提供给前端使用。graphdoc的用法很简单:
# Install graphdoc
npm install -g @2fd/graphdoc
# Generate documentation from live endpoint
graphdoc -e https://your.api.uri/graphql -o ./graphql-schema
当然,如果客户端使用的GraphQL框架为Apollo Client
,因此前端开发中测试与文档查看也可以使用Chrome浏览器的Apollo Client Developer Tools
插件。
UPDATE
目前最新版的graphql-java已经实现了导入special graphql dsl(IDL)
来创建后端schema的方法,并且推荐使用该方法来创建schema。使用IDL创建schema的详细方法可以参见官方文档.