GraphQLに基づいてAPIを構築するときにデータを操作する

前文



まず第一に、この記事はGraphQLに既に精通しており、GraphQLを使用することの微妙さとニュアンスについて詳しく知っている読者を対象としています。 それにもかかわらず、私はそれが初心者に役立つことを願っています。







GraphQLは素晴らしいツールです。 多くの人がすでにその利点を知っており、理解していると思います。 ただし、GraphQLベースのAPIを構築する際に注意すべき微妙な違いがあります。







たとえば、GraphQLを使用すると、コンシューマ(ユーザーまたはプログラム)に戻り、このコンシューマが関心を持っている部分のみのデータを要求できます。 それにもかかわらず、サーバーを構築するとき、間違いを犯すのは非常に簡単です。これは、サーバー内(特に分散可能)で、データが完全な「バンドル」で実行されるという事実につながります。 これは主に、GraphQL自体はすぐに使用できるクエリを解析するための便利なツールを提供しておらず、その中に配置されているインターフェイスは十分に文書化されていないという事実によるものです。







問題の原因



非最適な実装の典型的な例を見てみましょう(読みにくい場合は別のウィンドウで画像を開きます):







画像







私たちの消費者は、APIから私たちが保存したユーザーの識別子、名前、電話番号のみを要求する「電話帳」の特定のアプリケーションまたはコンポーネントであるとします。 同時に、私たちのAPIははるかに広範囲であり、居住地の物理アドレスやユーザーのメールアドレスなど、他のデータへのアクセスを許可します。







コンシューマとAPIの間のデータ交換の時点で、GraphQLは必要なすべての作業を完全に実行します。リクエストに応じてリクエストされたデータのみが送信されます。 この場合の問題は、データベースからデータをサンプリングする時点、つまり サーバーの内部実装では、一部の必要がないにもかかわらず、着信要求ごとにデータベースからすべてのユーザーデータを選択するという事実に基づいています。 これにより、データベースに過剰な負荷が発生し、システム内で過剰なトラフィックが循環します。 クエリの数が多い場合、データサンプリングのアプローチを変更し、要求されたフィールドのみを選択することで、大幅な最適化を実現できます。 同時に、データのソース(リレーショナルデータベース、NoSQLテクノロジー、または別のサービス(内部または外部))がまったく関係ありません。 最適化されていない動作は、実装によって影響を受ける可能性があります。 この場合のMySQLは、単に例として選択されています。







解決策



resolve()



関数に渡される引数を分析すると、このサーバーの動作を最適化できます。







 async resolve(source, args, context, info) { // ... }
      
      





この場合、特に興味深いのは、最後の引数info



です。 ドキュメントに目を向け、 resolve()



関数と関心のある引数が何で構成されるかを詳細に分析します。







 type GraphQLFieldResolveFn = ( source?: any, args?: {[argName: string]: any}, context?: any, info?: GraphQLResolveInfo ) => any type GraphQLResolveInfo = { fieldName: string, fieldNodes: Array<Field>, returnType: GraphQLOutputType, parentType: GraphQLCompositeType, schema: GraphQLSchema, fragments: { [fragmentName: string]: FragmentDefinition }, rootValue: any, operation: OperationDefinition, variableValues: { [variableName: string]: any }, }
      
      





したがって、リゾルバーに渡される最初の3つの引数はsource



-スキーマのGraphQLツリーの親ノードから渡されるデータ、 args



はクエリ引数(クエリから取得)、 context



は開発者が定義した実行コンテキストオブジェクトです。 「リゾルバ」で。 最後に、4番目の引数は、リクエストに関するメタ情報です。







問題を解決するためにGraphQLResolveInfo



から何を抽出できますか?







最も興味深い部分は次のとおりです。









そのため、解決策として、 info



ツールを解析し、クエリから取得したフィールドのリストを選択して、それらをSQLクエリに渡す必要があります。 残念ながら、Facebookの「すぐに使える」GraphQLパッケージは、このタスクを単純化するものを提供しません。 一般に、実践が示しているように、要求が断片化される可能性があるという事実を考えると、このタスクはそれほど簡単ではありません。 さらに、このような分析には普遍的なソリューションがあり、その後、プロジェクト間で簡単にコピーされます。







そこで、 ISCライセンスの下でオープンソースライブラリとして作成することにしました。 その助けを借りて、たとえば、次のような私たちのケースでは、着信クエリフィールドを解析するソリューションは非常に簡単に解決されます。







 const { fieldsList } = require('graphql-fields-list'); // ... async resolve(source, args, context, info) { const requestedFields = fieldsList(info); return await database.query(`SELECT ${requestedFields.join(',')} FROM users`) }
      
      





この場合のfieldsList(info)



はすべての作業を行い、このリゾルバの子フィールドの「フラット」配列を返します。 最終的なSQLクエリは次のようになります。







 SELECT id, name, phone FROM users;
      
      





受信リクエストを次のように変更した場合:







 query UserListQuery { users { id name phone email } }
      
      





SQLクエリは次のようになります。







 SELECT id, name, phone, email FROM users;
      
      





ただし、このような単純な課題に対応できるとは限りません。 多くの場合、実際のアプリケーションは構造がはるかに複雑です。 実装によっては、最終的なGraphQLスキーマのデータに関して上位レベルでリゾルバを記述する必要がある場合があります。 たとえば、 Relayライブラリを使用する場合、データオブジェクトのコレクションをページに分割する既製のメカニズムを使用します。これにより、GraphQLスキーマが特定のルールに従って構築されることになります。 たとえば、スキーマを次のように作り直します(TypeScript):







 import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; import { connectionDefinitions, connectionArgs, nodeDefinitions, fromGlobalId, globalIdField, connectionFromArray, GraphQLResolveInfo, } from 'graphql-relay'; import { fieldsList } from 'graphql-fields-list'; export const { nodeInterface, nodeField } = nodeDefinitions(async (globalId: string) => { const { type, id } = fromGlobalId(globalId); let node: any = null; if (type === 'User') { node = await database.select(`SELECT id FROM user WHERE id="${id}"`); } return node; }); const User = new GraphQLObjectType({ name: 'User', interfaces: [nodeInterface], fields: { id: globalIdField('User', (user: any) => user.id), name: { type: GraphQLString }, email: { type: GraphQLString }, phone: { type: GraphQLString }, address: { type: GraphQLString }, } }); export const { connectionType: userConnection } = connectionDefinitions({ nodeType: User }); const Query = new GraphQLObjectType({ name: 'Query', fields: { node: nodeField, users: { type: userConnection, args: { ...connectionArgs }, async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { // TODO: implement }, }, }); export const schema = new GraphQLSchema({ query: Query });
      
      





この場合、RelayのconnectionDefinition



は、 edges



node



pageInfo



およびcursor



ノードをスキームに追加しedges



。 クエリを別の方法で再構築する必要があります(現在はページネーションについては説明しません)。







 query UserListQuery { users { edges { node { id name phone email } } } }
      
      





そのresolve()



users



ノードに実装されresolve()



関数resolve()



フィールド自体ではなく、ネストされた子ノードnode



に対して要求されるフィールドを決定するedges.node



ます。







fieldsList



graphql-fields-list



ライブラリのfieldsList



は、この問題の解決に役立ちます。このため、対応するpath



オプションを渡す必要があります。 たとえば、ここでの実装は次のとおりです。







 async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { const fields = fieldsList(info, { path: 'edges.node' }); return connectionFromArray( await database.query(`SELECT ${fields.join(',')} FROM users`), args ); }
      
      





また、実世界では、GraphQLスキーマでは1つのフィールド名のみを定義し、データベーススキーマでは他のフィールド名がそれらに対応している場合があります。 たとえば、データベース内のユーザーテーブルの定義が異なるとします。







 CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, fullName VARCHAR(255), email VARCHAR(255), phoneNumber VARCHAR(15), address VARCHAR(255) );
      
      





この場合、GraphQLクエリのフィールドは、SQLクエリに埋め込む前に名前を変更する必要があります。 fieldsList



は、対応するtransform



オプションで名前変換マップを渡す場合に役立ちます。







 async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { const fields = fieldsList(info, { path: 'edges.node', transform: { phone: 'phoneNumber', name: 'fullName' }, }); return connectionFromArray( await database.query(`SELECT ${fields.join(',')} FROM users`), args ); }
      
      





それでも、場合によっては、フィールドのフラット配列への変換だけでは十分ではありません(たとえば、データソースが入れ子のある複雑な構造を返す場合)。 この場合、 graphql-fields-list



ライブラリのfieldsMap



関数がgraphql-fields-list



なり、要求されたフィールドのツリー全体をオブジェクトとして返します。







 const { fieldsMap } = require(`graphql-fields-list`); // ... some resolver implementation on `users`: resolve(arc, args, ctx, info) { const map = fieldsMap(info); /* RESULT: { edges: { node: { id: false, name: false, phone: false, } } } */ }
      
      





ユーザーが複雑な構造で記述されていると仮定すると、すべてが取得されます。 このメソッドは、オプションの引数path



も使用できます。これにより、ツリー全体から必要なブランチのみのマップを取得できます。次に例を示します。







 const { fieldsMap } = require(`graphql-fields-list`); // ... some resolver implementation on `users`: resolve(arc, args, ctx, info) { const map = fieldsMap(info, 'edges.node'); /* RESULT: { id: false, name: false, phone: false, } */ }
      
      





現在、カード上の名前の変換はサポートされておらず、開発者の容赦があります。







断片化のリクエスト



GraphQLはクエリの断片化をサポートします。たとえば、消費者がそのようなクエリを送信することを期待できます(ここでは、少し遠いが、構文的に正しい元のスキーマを参照します)。







 query UsersFragmentedQuery { users { id ...NamesFramgment ...ContactsFragment } } fragment NamesFragment on User { name } fragment AddressFragment on User { address } fragment ContactsFragment on User { phone email ...AddressFragment }
      
      





この場合は心配する必要はありません。この場合、 fieldsList(info)



およびfieldsMap(info)



はリクエストの断片化の可能性を考慮するため、期待される結果を返します。 したがって、 fieldsList(info)



は、 fieldsList(info)



['id', 'name', 'phone', 'email', 'address']



、およびfieldsMap(info)



を返します。







 { id: false, name: false, phone: false, email: false, address: false }
      
      





PS



この記事が、サーバー上でGraphQLを操作する際の微妙な点を明らかにするのに役立ちgraphql-fields-listライブラリが将来最適なソリューションを作成するのに役立つことを願っています。







UPD 1



ライブラリのバージョン1.1.0がリリースされました-要求内の@skip



および@include



サポートが追加されました。 デフォルトではこのオプションは有効になっていますが、必要に応じて次のように無効にします。







 fieldsList(info, { withDirectives: false }) fieldsMap(info, null, false);
      
      






All Articles