GraphQL服务开发指南

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, GraphQLShortGraphQLChar方便开发者使用。

其中需要注意的是,当对field选用了GraphQLID时,只会接受StringInteger类型的值并将其转换为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的文档

运用argumentdataFetcher,我们可以定义出一个庞大的数据图,而前端则根据该数据图自行定义查询。可以使用工具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的详细方法可以参见官方文档.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容