はじめに
去年(2018)春ごろから、Firebase
x React
x TypeScript
でアプリケーションを作成する機会が多くなり、
自分の中である程度書き方が固まってきたので、その内容をまとめることにしました。
フロントエンドの実装を想定した説明をしますが、サーバーサイドも似たような構造で筆者は書きます。
Firebase
、TypeScript
を使ったことがないかたは、少し難しいかもしれません。
「俺はこのような書き方してるよ〜」などあれば、コメントで教えて欲しいです!
アーキテクチャについて
筆者はFirebase
プロジェクトでの設計では、レポジトリパターンを使って設計することが多いです。
※厳密にレポジトリパターンになっているかといわれると、なっていないかも...
下記がフォルダ構造です。
src
├ entities
├ components
├ repositories
└ services
各層の責務と依存関係について
entities
使用するデータの型を定義したファイルを保存します。
各エンティティファイルでライブラリ以外の依存はさせないように疎結合に実装する。
src
├ entities
| ├ User.ts
| └ Post.ts
├ components
├ repositories
└ services
components
使用するコンポーネントファイルを保存します。
Atomic Design
に基づいた切り方、依存関係でコンポーネントを作成する。
※詳しくはAtomic Design
で調べて下さい。
repositories
DB(主にFirestore
)に保存されているデータの読み込み・書き込みのコードをレポジトリ内で隠蔽できるように書く。
各レポジトリファイルで**entitiles
もしくはライブラリ以外の依存はさせないように実装する**。
src
├ entities
├ components
├ repositories
| ├ user.ts
| └ post.ts
└ services
services
日付変換やカスタムフック(Hooks
)、Firestore
のスナップショットのリッスンなどを書く。
repositories
だけでは再現できない複雑なロジックを含んだ関数などを定義するイメージです。
各サービスファイルは他のサービスファイル、entities
、そしてライブラリ全てに対して依存しても良いです。
※腐敗防止的な役割もになっています。
src
├ entities
├ components
├ repositories
└ services
├ user.ts
└ post.ts
firestore周りの実装について
Firestore
とのやり取りは基本的にrepositories
内で閉じさせるように実装しますが、
例外として、リアルタイムにデータを取得するために使用するスナップショットのリッスン(チャット実装などで必須)はservicesにカスタムフック
として書きます。
リッスンをrepositories
に書かない理由は、「repositories
をget
とset
のシンプルな形で実現させたい」「リッスンはDB
の読み込みではなく、リアルタイムに保存されているデータの変更を検知するいちサービスとして捉えることにした」です。
ここら辺は、賛否両論あると思うのでコメントにて色々指摘欲しいです。
各層の書き方
entitiesの書き方
entities
には、保存するモデルの型と型に沿ったデータ変換を主に書くことになります。
エンティティの書き方を見る前に、Firestore
のデータ設計について軽く触れます。
firestore
はコレクションとドキュメントの階層構造になっているので、
ドキュメントにモデル情報を保存し、コレクションで複数のモデルをまとめるような設計になる。
下記はUserモデルを保存する場合の構造例です。
Users(collection)
├ kurino(document)
├ satou(document)
├ tarou(document)
└ hanako(document)
上記の構造からわかるように、document
に対して型を添えると相性がいいです。
User
の型を定義し利用することで、kurino
, satou
, ... は型にハマったデータが保存されていることになるので扱いやすくなります。
また型をかませるたデータに変換する関数を用意しておくと、Firestore
のデータ読み込み時に便利です。
下記はUser
の型を定義する例です。
src
├ entities
| └ User.ts // ここ
├ components
├ repositories
└ services
export type User = {
name: string
age?: number
gender: 'male' | 'female' | 'other'
}
export type UpdateUser = {
name?: string
age?: number
gender?: 'male' | 'female' | 'other'
}
export const buildUser = (data: firebase.firestore.DocumentData) => {
const user: User = {
name: data.name
age: data.age
gender: data.gender
}
return user
}
entities
にデータの型を定義することで、
どのようなデータがFirestore
に保存されているのか、そしてどのようなデータを保存していいのか明確になります。
componentsの書き方
こちらは、TypeScript
の書き方というよりも、コンポーネントの粒度をどうするかの話になってくるので説明しません。とりあえず、Atomic Design
を学びましょう。
repositoriesの書き方
repositories
には、entities
で定義した型のモデルデータ(document
)の読み込みと書き込みを実装します。
基本的に、createXXXX
, updateXXXX
, getXXXX
, setXXXX
の名前の関数が並びます。
下記はUser
のデータ読み込み・書き込みの例です。
src
├ entities
| └ User.ts
├ components
├ repositories
| └ user.ts // ここ
└ services
const db = firebase.firestore()
const usersRef = db.collection('users')
export const getUser = (id: string) => {
try {
const snapshot = await usersRef.doc(id).get()
const user = buildUser(snapshot.data()) // entitiesで定義した変換関数
return user
} catch (e) {
console.warn(e)
return null
}
}
// UpdateUserはentitiesで定義した型
export const setUser = (id: string, user: UpdateUser ) => {
try {
const batch = db.batch()
batch.set(
usersRef.doc(uid),
{
...(user.name && { name: user.name }),
...(user.age && { age: user.age }),
...(user.gender && { gender: user.gender })
},
{ merge: true }
)
await batch.commit()
return { result: true }
} catch (e) {
console.warn(e)
return { result: false }
}
}
repositories
にデータ参照・編集の役割を漏れ出さないように実装することで、
DB
周りの操作においてどのように実装しようか迷うことがなくなり、保守・拡張もしやすくなります。
servicesの書き方
services
には、entities
やrepositories
などを利用して、実際にcomponents
や画面で使用する関数やカスタムフックを実装します。
下記はチャットを実装する場合の例で、User
に紐づくmessages
のスナップショットのリッスンです。
※本来はrooms
など区切って実装しますが、簡略化する為そこらへんは無視して実装しています。
src
├ entities
| └ User.ts
├ components
├ repositories
| └ user.ts
└ services
└ chat.ts // ここ
const db = firebase.firestore()
const usersRef = db.collection('users')
export const useChat = (userID: string) => {
const messagesRef = usersRef.doc(userID).collection('messages').orderBy('createdAt', 'desc')
const [messages, setMessages] = useState<Message[]>() // entitiesにMessageが定義されている想定。
useEffect(() => {
const unsubscribe = messagesRef.onSnapshot({
next: (snapshot: firebase.firestore.QuerySnapshot) => {
const messages = snapshot.docs
.map(doc => {
const message = buildMessage(doc.id, doc.data()) // repositoriesにbuildMessageがある想定。
return message
})
setMessages(messages)
},
error: (error: Error) => {
console.warn(error)
}
})
return () => {
unsubscribe()
}
}, [messagesRef])
const onSend = useCallback((text: string) => {
createMessage(text) // repositoriesにcreateMessageがある想定。
}, [])
return { messages, onSend }
}
下記はモーダル開閉を管理するカスタムフックの例です。
src
├ entities
| └ User.ts
├ components
├ repositories
| └ user.ts
└ services
├ chat.ts
└ modal.ts // ここ
export const useModal = () => {
const [isVisible, setIsVisible] = useState<boolean>(false)
const onOpen = useCallback(() => {
setIsVisible(true)
}, [])
const onClose = useCallback(() => {
setIsVisible(false)
}, [])
return { isVisible, onOpen, onClose }
}
services
に実際にcomponents
で利用する関数を書くことで、
複雑なロジックを含まないシンプルなrepositories
と、見た目にのみ徹したcomponents
の実現を可能にし、再利用可能な関数をまとめあげることができます。
おまけ(Tips)
timestampを上手に扱う
基本的に何もなければ、createdAt
, updatedAt
をdocument
に保存すると思います。
その時、entities
にDocument.ts
を作っておくと便利です。
export const createDocument = <T>(document: T) => {
return {
...document,
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp()
}
}
export const updateDocument = <T>(document: T) => {
return {
...document,
updatedAt: firebase.firestore.FieldValue.serverTimestamp()
}
}
上記のエンティティ関数を使って、本編で定義したrepositories
を書き換えると...
const db = firebase.firestore()
const usersRef = db.collection('users')
export const setUser = (id: string, user: UpdateUser) => {
try {
const batch = db.batch()
batch.set(
usersRef.doc(uid),updateDocument<UpdateUser>({
...(user.name && { name: user.name }),
...(user.age && { age: user.age }),
...(user.gender && { gender: user.gender })
}),
{ merge: true }
)
await batch.commit()
return { result: true }
} catch (e) {
console.warn(e)
return { result: false }
}
}
ステートのグローバル管理(ContextAPI, Redux)
React
標準搭載のContextAPI
、王道と名高いRedux
どちらにしろ別フォルダを切ってコードを書いた方がすっきりします。
下記がuser
をグローバル管理したときのフォルダ構造です。
src
├ entities
├ components
├ repositories
├ services
└ store
├ user
: ├ actions
├ state
└ reducers
気を付けることは、repositories
を汚さないことを意識する。
つまり、services
とcomponents
からのみグローバルステートのDispatch
を行う。
さいごに
TypeScript
を使用した全体の具体的な設計の話をしている記事が少ないと感じたので、今回自分なりに書かせて頂きました。これは筆者が、1年ほどFirebase
x React
x TypeScript
でアプリケーションを作成して固まった設計なのでまだまだたくさん改善の予知はあると思います。これからFirebase
x React
x TypeScript
でアプリケーションを作成することがあるとき少しでも助けになれれば嬉しいです。
最近活発にコミットを積んでいるReact Native
で書かれたレポジトリを載せておきます。
実際に、これまで説明した設計で実装を進めていますので、参考になるかもしれません。
最後までお読みいただきありがとうございました!
Thank you !