Kosaku Kurino

Kosaku Kurino

1年ほどFirebase x TypeScriptで開発してきて定まってきた僕なりの書き方

はじめに

去年(2018)春ごろから、Firebase x React x TypeScriptでアプリケーションを作成する機会が多くなり、 自分の中である程度書き方が固まってきたので、その内容をまとめることにしました。

フロントエンドの実装を想定した説明をしますが、サーバーサイドも似たような構造で筆者は書きますFirebaseTypeScriptを使ったことがないかたは、少し難しいかもしれません。

「俺はこのような書き方してるよ〜」などあれば、コメントで教えて欲しいです!

アーキテクチャについて

筆者は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に書かない理由は、「repositoriesgetsetのシンプルな形で実現させたい」「リッスンは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には、entitiesrepositoriesなどを利用して、実際に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, updatedAtdocumentに保存すると思います。 その時、entitiesDocument.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を汚さないことを意識する。 つまり、servicescomponentsからのみグローバルステートのDispatchを行う

さいごに

TypeScriptを使用した全体の具体的な設計の話をしている記事が少ないと感じたので、今回自分なりに書かせて頂きました。これは筆者が、1年ほどFirebase x React x TypeScriptでアプリケーションを作成して固まった設計なのでまだまだたくさん改善の予知はあると思います。これからFirebase x React x TypeScriptでアプリケーションを作成することがあるとき少しでも助けになれれば嬉しいです。

最近活発にコミットを積んでいるReact Nativeで書かれたレポジトリを載せておきます。 実際に、これまで説明した設計で実装を進めていますので、参考になるかもしれません。

https://github.com/kousaku-maron/party

最後までお読みいただきありがとうございました! Thank you !