以下の2つのPull Requestで諸々のログイン前: SQLite、ログイン後:GraphQL構成への移行が完了しました 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.Collection("version/1/users/1/calendars").Where("date", ">=", "2020-01-01T00:00:00").Where("date", "<=", "2020-01-31T23:59:59")
ライフログ
1日に表示する分のデータが必要なので以下のようなクエリで取得
f.Collection("version/1/users/1/calendars/2020-01-01")
ライフログ詳細
ここでは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とは
- GraphQLのスキーマファイルからGraphQLのハンドラーとtypeとmodelを自動生成してくれるライブラリです。
詳しくは以前、記事にしているので以下参照
gqlgenとgraphql-codegenでGraphQLのtypeを自動生成してフロントエンドのコード作成する - ペペロミア開発ブログ
作成したGraphQL一覧
https://github.com/wheatandcat/PeperomiaBackend/blob/master/schema.md#query
アプリの実装
アプリでのGraphQL関係のコードはgraphql-codegenを使用することで自動生成することが可能です。
graphql-codegenでQuery(読み込み)に対して自動生成されるもの
gqlファイルを元に、以下の必要なコードが自動生成されます。
自動生成されたコードを使用してデータを取得する
自動生成された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ファイルを元に、以下の必要なコードが自動生成されます。
自動生成されたコードを使用してデータを更新する
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
ホーム画面
ホーム画面は登録一覧から、カレンダーに変更。 ボトムタブも変更して、カレンダー、今日の予定、設定に変更 ※データ構造をカレンダーベースに変更した流れで画面設計合わせて変更
ライフログ画面
縦軸の一覧表示から、マルチデバイスを意識したデザインに変更。 ライフログアプリに時間の概念は不要と判断したため削除
ライフログ詳細画面
ほぼ変更なし。 時間の表示のみ削除
最後に
残作業はPush通知のdeep link周りの対応くらいなので、諸々修正したら、v3.0をリリースします。