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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375

推薦閱讀更多精彩內容