フロントエンド開発を安全かつ素早くできるように取り組んでいること
TECH
2023.06.28
更新日:2024.07.18
公開日:2024.07.18
こんにちは、昨年10月にPLAN-Bに中途入社したフロントエンドエンジニアの李 熙途です。
入社以来、Cast Me!のフロントエンド開発に携わっています。Cast Me!では、サービスに柔軟な機能追加や対応を行うため、フロントエンドにGraphQLとRelayを積極的に活用しており、私も入社後に初めてこれらの技術を使用しました。
Cast Me!は、株式会社PLAN-Bが提供するSNSマーケティングのSaaSツールです。日々活発に新しい機能の開発を進めており、2024年5月には、Cast Me! InstagramDM (InstagramDMの自動化ツール) Cast Me! Survey (情報収集と効果計測をお客様のウェブサイト上で行えるアンケートツール)という機能のリリースを完了いたしました。
(詳細はこちら)
複数の幅広い機能を開発するためには、柔軟性を持つ設計が必要となります。そのためCast Me!のフロントエンドは、Nest.js BFF(GraphQL)とNext.jsで構成されています。 Nest.jsでは、バックエンドAPIとの通信を担当し、GraphQLのSchemaとQuery、Mutationが定義されています。 Next.jsでは、Nest.jsで定義したGraphQLのQuery, Mutationを使用してUIと機能を実装しています。
この記事では、入社後にGraphQLとRelayを使用して、その際に感じたことについて紹介していきます。
Cast Me!では、ユーザーのアカウントにInstagramおよびTikTokの投稿アカウントを連携することができます。そのため、ユーザー情報を取得するAPIと、ユーザー情報のIDを基点として連携されたInstagram、TikTokの情報を取得するAPIを使用しています。
ページによってはユーザー情報、InstagramやTikTokの情報が同時に必要な場合があります。このような2つ以上のAPIからデータを取得する必要がある場合には、Nest.js GraphQLではResolveFieldというものをよく使います。
Nest.jsでGraphQLを使用する際には、Controllerの代わりにResolverを使用します。Resolverは、GraphQLのQueryとMutationを受け取り、responseをQueryの形式に合わせて返す役割を果たします。
Resolverから返されるEntityにFieldを追加したい場合、ResolveFieldを使用します。ResolveFieldを使用することで、Queryに必要なデータを柔軟に追加することが可能になります。
例えば、「ユーザーの情報を取得するAPI」と、その中のuserIdを使用して「ユーザーが書いたコメントを取得するAPI」の2つのAPIが存在するとします。
アプリケーションの特定のページでユーザーの情報を使用しつつ、ユーザーが書いたコメントのデータも使用したい場合、Nest.jsでは以下のようにResovleFieldを使用してResolverを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import { Parent, ResolverField, Resolver, Query } // Resolverで返却するEntity @Resovler(() => User) class UserResolver { constructor ( ... ) {} @Query(() => User) async user ( @AuthorizationHeader header ) { ... // APIと通信してデータを返却する処理 } @ResolveField(() => UserComments) async comments ( @AuthorizationHeader header @Parent() user: User ) { ... const searchUserCommentsInput = new SearchUserCommentsInput(..., user.userId,....) ...// APIと通信してデータを返却する処理 } } |
フロントエンドで使用する時は下記のようにQueryにResovleFieldで作成したFieldをリクエストするだけで使用することができます。
1 2 3 4 5 6 7 8 |
query { user { .... comments { // <-- ResolveFieldで追加したField ... } } } |
Queryがフロントで実行されてから、まず、Nest.jsのResolverに定義したasync userの部分が実行され、APIからuserデータを取得します。その後、ResovleFieldのasync commentsの部分が実行され、async userから取得したUserデータのuserIdを利用してコメントを取得するAPIとの通信を行います。APIとの通信が完了したら、リクエストしたQueryに合わせてResolverがデータを返却します。
このおかげで、フロントエンドでは追加でAPIをfetchするなどの作業の必要がなくなり、ResolveFieldで定義されたFieldを使用するだけで、必要に応じてデータを取得し使用することができるようになります。
また、GraphQLの各FieldはSchemaに定義されているため、フロントエンドではデータの型を確認して機能を実装することができ、コードの安定性が向上します。
Relayは、各コンポーネントが必要とするデータをGraphQLのFragmentで表現し、コンポーネントを使用する親コンポーネントのQueryやFragmentから、該当コンポーネントで使用されるFragmentデータを取得し、Propsとして渡します。
ユーザーの情報を表示するUserInfoというコンポーネントがあるとします。
このコンポーネントでユーザーの情報とユーザーの情報を起点とした他の情報も必要な場合はコンポーネントで Fragmentを作成してコンポーネントで必要なfieldをFragmentに定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// exmple UserInfo component const USER_INFO_FRAGMENT = graphql` fragment UserInfo_user on User { id name instagram { id } } `; type Props = { // UserInfo_user$keyはFragmentに問題がない場合 // relay-complierによって自動で生成されます user: UserInfo_user$key; }; export const UserInfo = ({ user }: Props) => { const data = useFragment(USER_INFO_FRAGMENT, user); return ( <div> <p>{data.userId}</p> <p>{data.name}</p> <p>{data.instagram.mediaId}</p> .... </div> ) } |
このコンポーネントを使用する親コンポーネントは下記のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// example component const EXAMPLE_COMPONENT_QUERY = graphql` query ExampleComponentQuery { project { ... ...UserInfo_user, ... } } `; export const ExampleComponent = () => { ... // ジェネリックスに指定しているExampleComponentQuery Typeは // 上記に作成したEXAMPLE_COMPONENT_QUERYのGraphQL Queryが問題ない場合 // relay-compilerで自動で生成します。 const data = useLazyLoadQuery<ExampleComponentQuery>( EXAMPLE_COMPONENT_QUERY, {}, ); ... return ( <div> ... <UserInfo user={data.user}/> ... </div> ) } |
Fragmentは直接fetchすることはできないため、他のFragmentやQueryに記載することで使用します。
親コンポーネントでは子コンポーネントのFragmentを記載してPropsを渡すだけで済むため、親コンポーネントが子コンポーネントで使用されるfieldについては詳しく記載する必要がなくなります。
各コンポーネントのFragmentには、そのコンポーネントが必要とするfieldが記載されているため、コンポーネントの間の依存性が減少します。
また、コンポーネントで取得するデータを変更したい場合は、定義しているFragmentに変更を加えるだけでよいため、メンテナンス面でも利点があります。
Cast Me!でもFragmentを積極的に使用しており、各コンポーネントの間の依存性が弱く、コンポーネントの共通化をもっと効率的に実装することができるようになりました。また、Fragmentが記載されているコンポーネントを使用する際に、Fragmentのスプレッドを忘れる場合や想定していないQueryでスプレッドさせている場合relay-compilerで警告してくれるのでコードの品質も向上することができました。
APIからデータを取得して表示するページでは、APIのfetchが完了するまで時間がかかる場合があります。
データのfetchが完了するまで何も表示しないと、ユーザーには白いページが見えたり、アプリケーションから何のフィードバックもないため、アプリケーションが停止したかのような錯覚を与え、速度が遅いと感じたり、不安を抱かせることがあります。
そのため、ユーザーにはSpinnerやSkeletonのようなLoading UIを表示して、アプリケーションが正常に動作していることを認識させることがユーザー体験において重要です。
Reactでは、APIとの通信のような非同期処理を行う時のUI管理をしやすくするSuspenseという機能を提供しています。RelayはReactのSuspenseに対応しているため、Suspenseを使用してLoading UIを宣言的に使用することができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const EXMAPLE_COMPONENT_QUERY = graphql` query ExmapleComponentQuery { user { userId .... ...ExampleChildComponet_user } } ` export const ExampleComponet = () => { // GraphQLのQueryをfetchします。 const data = useLazyLoadQuery<ExmapleComponentQuery>(EXMAPLE_COMPONENT_QUERY, {}) return ( <div> <p>{data.userId}</p> .... </div> ) } |
1 2 3 4 5 6 7 |
export const ExampleParentComponent = () => { return ( <Suspense fallback={<p>...Loading</p>}> <ExmapleComponet /> </Suspense> ) } |
上記のように、useLazyLoadQueryはGraphQLのQueryをfetchする機能を持っているRelayのhookであり、GraphQL Queryをclient側で実行する際に使用します。また、Suspenseに対応しているのでSuspenseを使用することでQueryを実行の間、自分が設定したLoading UIを表示できます。
Cast Me!でも同様の方法でGraphQLを実行をしてコンポーネントに必要なデータがレスポンスされる間にSpinnerやSkeleton UIを表示しています。これにより、サービスを利用するユーザーにアプリケーションが停止しているのではなく、正常に動作していることを認識させることで、ユーザー体験を向上させています。
これまでは一般的なREST APIのみを使用していましたが、入社後、初めてGraphQLとRelayを使用することになり、REST APIに比べて馴染みのない使用法や、Relayのドキュメントの不足による困難もありました。
しかし、慣れてきた今は、柔軟に対応できるGraphQLと、さまざまな機能を提供するRelayを使用して、新しいサービスの開発に積極的に取り組んでいます。
PLAN-Bでは、GraphQLRelayに加えて、Jestを使用したコンポーネント単位のテストの導入や、PandaCSSをデザインシステムやプロダクト開発に積極的に導入するなど、さまざまなフロントエンド技術の調査と導入を進めています。
モダンなフロントエンド開発に興味がある方は、下記のリンクから応募ください。