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
,我們可以定義出一個龐大的數據圖,而前端則根據該數據圖自行定義查詢??梢允褂霉ぞ?a target="_blank" rel="nofollow">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的詳細方法可以參見官方文檔.