wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

ログイン前: SQLite、ログイン後: write:RESTful API + read:Firestoreからログイン前: SQLite、ログイン後:GraphQL構成への移行

以下の2つのPull Requestで諸々のログイン前: SQLite、ログイン後:GraphQL構成への移行が完了しました github.com

github.com

ペペロミアのv2→v3のバージョンアップで、この辺の構成を一気に修正される予定です。 なので、改めてv2→v3への変化を記事にまとめました。

v2の構成

ペペロミアはv2は以下の構成で作成されていました。

  • ログイン前: SQLiteでデータを管理
  • ログイン後: Firestoreでデータを管理
    • 書き込み: RESTful APIでサーバーを経由してFirestoreに書き込む
    • 読み込み: clientから直接Firestoreを読み込む
  • APIドキュメント: Swagger

v2構成での問題点

  • ログイン前のSQLiteのDB構成を、そのままFirestoreのデータ構成にしていたのでFirestoreのメリットが活かせず、デメリットが目立った
  • clientから直接Firestoreを読み込む処理を宣言する関係でアプリのコードが多くなり、複雑になった
  • RESTful APIやFirestoreに関するTypeをフロントエンド、バックエンドのプロジェクト毎に別コードで管理するのが辛い
  • Firestoreのsecurity rulesが複雑で管理が難しい
  • Swaggerの更新を忘れる or 実装と乖離がおきる

Firestoreの特徴

  • データモデルは階層型データ構造
  • RDBのJOINは存在しない
  • where-inやarray-contains-anyは、双方10件までしか取得できない

結論: 上記の性質からRDBと同じような設計思想で進めると、どこかしらで躓く といいう事でv3で、どの辺を改善したかという話が次になります。

v3の構成

ペペロミアはv3は以下の構成で作成しました

  • ログイン前: SQLiteでデータを管理
  • ログイン後: Firestoreでデータを管理
    • 書き込み: GraphQLのMutation
    • 読み込み: GraphQLのQuery

v3の構成での改善点

ログイン前のSQLiteのDB構成を、そのままFirestoreのデータ構成にしていたのでFirestoreのメリットが活かせず、デメリットが目立った 
  • データ構成をFirestoreを活かせる作りに変更した
clientから直接Firestoreを読み込む処理を宣言する関係でアプリのコードが多くなり、複雑になった
  • Firestoreのアクセスコードが全てbackend側になったのでアプリ側の実装がシンプルになった
RESTful APIやFirestoreに関するTypeをフロントエンド、バックエンドのプロジェクト毎に別コードで管理するのが辛い
  • graphql-codegenを使用することでtypeは全て自動生成できるようになった
Firestoreのsecurity rulesが複雑で管理が難しい
  • Firestoreのアクセスが全てbackendになったため考慮する必要がなくなった
Swaggerの更新を忘れる or 実装と乖離がおきる
  • APIドキュメントは、graphql-markdownで自動生成できるので実装との乖離が起きなくなった

逆にv3の構成での失ったもの

  • Firestoreのアクセス全てbackendで実装したためリアルタイムアップデートの機能は失った

※一応GraphQLのSubscriptionsを実装すれば、リアルタイムアップデートの実装も可能だがコストと見合わないため今回は見送っています

v2でのFirestoreのデータ構成

├──users/:id
├──calendars/:id
├──items/:id
├──itemDetails/:id
└──expoPushTokens/:id

v3でのFirestoreのデータ構成

version/1
    └── users/:id
             ├── expoPushTokens/:id 
             └── calendars/:date 
                           └──items/:id
                                  └──itemDetails/:id

v3でのFirestoreのデータ構成のメリット

  • user_idのみでユーザーの全情報を取得できる
  • user_idと日付でCalendarの情報が取得できる
  • calendarのdocumentが日付なので、必ずユニークの構造になる

各画面とデータの読み込み

カレンダー

f:id:wheatandcat:20201213193029p:plain:w200

カレンダーに表示する分のデータが必要なので以下のようなクエリで取得

f.Collection("version/1/users/1/calendars").Where("date", ">=", "2020-01-01T00:00:00").Where("date", "<=", "2020-01-31T23:59:59")

ライフログ

f:id:wheatandcat:20201213193115p:plain:w200

1日に表示する分のデータが必要なので以下のようなクエリで取得

f.Collection("version/1/users/1/calendars/2020-01-01")

ライフログ詳細

f:id:wheatandcat:20201213193158p:plain:w200

ここではidだけデータを取得したいのでCollectionGroupを使用して取得

 f.CollectionGroup("itemDetails").Where("id", "==", "efjhij")

CollectionGroup

Firestoreのデータ構造上、親階層のDocuementIDの情報を持っていない限り、子階層へのアクセスはできませんが、 CollectionGroupを使用することで、横断的にアクセスすることができます。

例 )

通常のitemDetailのアクセス

f.Collection("version/1/users/1/calendars/2020-01-01/items/abcded/itemDetails").Where("id", "==", "efjhij")

CollectionGroupを使用したアクセス

f.CollectionGroup("itemDetails").Where("id", "==", "efjhij")

CollectionGroupの注意点

  • DocuementIDが重複する可能性がある
  • コレクションIDが一致すればデータ構造関係無しにデータを取得してしまう
    • 別階層に同名のコレクションIDが存在する場合でもデータは取得できる
  • Whereは単一かつ範囲系の条件は指定できない

データ構造変更によるマイグレーション

もちろん、構造の変更するには実際のデータマイグレーションが必要なので、以下でマイグレーションスクリプトを作成

https://github.com/wheatandcat/PeperomiaTool/tree/master/FirestoreMigration

詳しくは以前、記事にしているので以下参照

Firestoreを新設計にマイグレーションする - ペペロミア開発ブログ

GraphQLの実装

GraphQLはgqlgenを使用して実装

gqlgenとは

github.com

  • GraphQLのスキーマファイルからGraphQLのハンドラーとtypeとmodelを自動生成してくれるライブラリです。

詳しくは以前、記事にしているので以下参照

gqlgenとgraphql-codegenでGraphQLのtypeを自動生成してフロントエンドのコード作成する - ペペロミア開発ブログ

作成したGraphQL一覧

https://github.com/wheatandcat/PeperomiaBackend/blob/master/schema.md#query

アプリの実装

アプリでのGraphQL関係のコードはgraphql-codegenを使用することで自動生成することが可能です。

graphql-code-generator.com

graphql-codegenでQuery(読み込み)に対して自動生成されるもの

gqlファイルを元に、以下の必要なコードが自動生成されます。

f:id:wheatandcat:20201213224255p:plain

自動生成されたコードを使用してデータを取得する

自動生成されたuseCalendarQueryに引数を渡すことでgraphqlのデータを取得できます。

import React ,{ memo } from 'react';
import { useCalendarQuery } from 'queries/api/index';


const Connected: React.FC<Props> = memo((props) => {

  const { data, loading, error } = useCalendarQuery({
    variables: {
      date: props.date,
    },
    fetchPolicy: 'network-only',
  });

graphql-codegenでMutation(書き込み)で自動生成されるもの

gqlファイルを元に、以下の必要なコードが自動生成されます。

f:id:wheatandcat:20201213230150p:plain

自動生成されたコードを使用してデータを更新する

import React ,{ memo, useCallback } from 'react';
import { useCreateCalendarMutation, NewItem } from 'queries/api/index';

const Connected: React.FC<Props> = memo((props) => {
  const [ createCalendarMutation ] = useCreateCalendar({
    async onCompleted({ createCalendar }) {

      props.navigation.navigate('Calendar', {
        date: dayjs(createCalendar.date).format('YYYY-MM-DDT00:00:00'),
      });
    },
    onError(err) {
      Alert.alert('保存に失敗しました', err.message);
    },
  });

  const onSave = useCallback(
    (item: NewItem) => {
      const variables = {
        calendar: {
          date: props.date,
          item,
        },
      };

      createCalendarMutation({ variables });
    },
    [createCalendarMutation, props.date]
  );

SQLiteとGraphQLの切り替え

ログイン前はSQLite、ログイン後はGraphQL(Firestore) でデータ管理しているので、 その部分はCustom Hooksを使用して実装しています。

■ hooks/useCreateCalendar.tsx

import { useAuth } from 'containers/Auth';
import {
  CalendarQueryHookResult,
  useCalendarQuery,
  CalendarQueryVariables,
} from 'queries/api/index';
import { WatchQueryFetchPolicy } from '@apollo/client';
import useCalendarDB from 'hooks/db/useCalendarDB';
import { isLogin } from 'lib/auth';

type UseCalendar = Pick<
  CalendarQueryHookResult,
  'data' | 'loading' | 'error' | 'refetch'
>;

type Props = {
  variables: CalendarQueryVariables;
  fetchPolicy?: WatchQueryFetchPolicy;
};

type UseHooks = (props: Props) => UseCalendar;

const useCalendar = (props: Props): UseCalendar => {
  const { uid } = useAuth();
  let useHooks: UseHooks;

  if (uid && isLogin(uid)) {
    useHooks = useCalendarQuery;
  } else {
    useHooks = useCalendarDB as any;
  }

  const { data, loading, error, refetch } = useHooks(props);

  return { data, loading, error, refetch };
};

export default useCalendar;

SQLiteのCustom Hooks

ちなみに、SQLiteのCustom HooksはGraphQL側のインターフェースに合わせて、こんな感じに作成

■ src/hooks/db/useCalendarDB.tsx

import * as SQLite from 'expo-sqlite';
import { useState, useEffect, useCallback } from 'react';
import equal from 'fast-deep-equal';
import { ApolloError, WatchQueryFetchPolicy } from '@apollo/client';
import useIsFirstRender from 'hooks/useIsFirstRender';
import {
  CalendarQuery,
  CalendarQueryVariables,
  CalendarQueryHookResult,
} from 'queries/api/index';
import { SelectCalendar } from 'domain/calendar';
import { findDate } from 'lib/db/calendar';
import usePrevious from 'hooks/usePrevious';
import { db } from 'lib/db';

type State = {
  data: CalendarQuery;
  loading: boolean;
  error: ApolloError | null;
  refetch: CalendarQueryHookResult['refetch'];
};

const initialState = (): State => {
  return {
    data: {
      calendar: null,
    },
    loading: true,
    error: null,
    refetch: () => null as any,
  };
};

type Props = {
  variables: CalendarQueryVariables;
  fetchPolicy: WatchQueryFetchPolicy;
};

const useCalendarDB = ({ variables }: Props) => {
  const prevVariables = usePrevious(variables);
  const [state, setState] = useState<State>(initialState());
  const isFirstRender = useIsFirstRender();

  const fetchItem = useCallback(async (): Promise<SelectCalendar> => {
    return new Promise(function (resolve, reject) {
      db.transaction((tx: SQLite.SQLTransaction) => {
        findDate(tx, variables.date, (data, err) => {
          if (err) {
            reject(null as any);
            return;
          }

          resolve(data as any);
          return;
        });
      });
    });
  }, [variables]);


  const setItem = useCallback(async () => {
    setState((s) => ({
      ...s,
      loading: true,
    }));

    const result: SelectCalendar = await fetchItem();

    setState((s) => ({
      ...s,
      data: {
        calendar: {
          id: result.id,
          date: result.date,
          public: false,
          item: {
            id: result.itemId,
            title: result.title,
            kind: result.kind,
          },
        },
      } as any,
      loading: false,
    }));
  }, [fetchItem]);

  useEffect(() => {
    if (!isFirstRender) return;

    setItem();
  }, [isFirstRender, setItem]);

  useEffect(() => {
    if (!equal(variables, prevVariables)) {
      setItem();
    }
  }, [variables, prevVariables, setItem]);

  return {
    ...state,
    refetch: setItem,
  };
};

export default useCalendarDB;

データ設計に合わせて画面設計も変更

今回のデータ設計の変更によりライフログは全てカレンダーに紐づくようになったので、 諸々画面設計の見直しをしました。

version/1
    └── users/:id
             ├── expoPushTokens/:id 
             └── calendars/:date 
                           └──items/:id
                                  └──itemDetails/:id

ホーム画面

ホーム画面は登録一覧から、カレンダーに変更。 ボトムタブも変更して、カレンダー、今日の予定、設定に変更 ※データ構造をカレンダーベースに変更した流れで画面設計合わせて変更

f:id:wheatandcat:20201214000548p:plain

ライフログ画面

縦軸の一覧表示から、マルチデバイスを意識したデザインに変更。 ライフログアプリに時間の概念は不要と判断したため削除

f:id:wheatandcat:20201214000852p:plain

ライフログ詳細画面

ほぼ変更なし。 時間の表示のみ削除

f:id:wheatandcat:20201214001347p:plain

最後に

残作業はPush通知のdeep link周りの対応くらいなので、諸々修正したら、v3.0をリリースします。