📖

Firebase × Plop × モノレポで叶えるコードジェネレーターのある生活

2023/08/07に公開

概要

Firebase FunctionsのコードをNestJSのようなジェネレーターを利用して開発するためのテンプレートを作りました。このジェネレーターを利用することで、事前に定義したスキーマから、CRUDのためのコード、テストコード、フロントエンドとバックエンドの型を共有できるコードを自動生成することができます。

https://github.com/dar0xt/monorepo-firebase-nextjs

できること

こんな感じのスキーマを与えると、

/generators/schema/post.json
{
  "$schema": "./schema.json",
  "feature": {
    "name": "post"
  },
  "attributes": [
    {
      "name": "postId",
      "type": "string",
      "nullable": false
    },
    {
      "name": "title",
      "type": "string",
      "nullable": false
    },
    {
      "name": "content",
      "type": "string",
      "nullable": true
    },
    {
      "name": "createdAt",
      "type": "date",
      "nullable": false
    },
    {
      "name": "updatedAt",
      "type": "date",
      "nullable": false
    }
  ]
}

こんな感じのコードが自動生成されます。

/functions
- post.controller.ts
- post.dto.ts
- post.service.ts
- post.service.ts
- post.model.ts
- post.collection.ts

/shared
- post.validation.ts

たとえばpost.controller.tsでは以下のようにonCallを利用したリクエストハンドラーを定義しています。

/functions/controller/post.controller.ts
export class PostController {
  constructor(
    @inject('FirebaseFunctions') private functions: FirebaseFunctions,
    @inject('PostService') private service: PostService
  ) {}
  get() {
    return this.functions.onCall<GetPostRequest, GetPostResponse>(
      async (request) => {
        const { postId } = getPostRequestSchema.parse(request.data)
        const post = await this.service.get(postId)
        return getPostResponseSchema.parse(post)
      }
    )
  }
 }
 export const getPost = controller.get()

ここでgetPost(Request/Response)SchemaおよびGetPost(Request/Response)/sharedにおいて、以下のように定義されています。

/shared/interface/post.validation.ts
export const getPostRequestSchema = z.object({
  postId
})
export const getPostResponseSchema = z.nullable(
  z.object({
   postId,
title,
content,
createdAt: createdAt.transform((date) => date.toISOString()),
updatedAt: updatedAt.transform((date) => date.toISOString()),
  })
)
export type GetPostRequest = z.infer<typeof getPostRequestSchema>
export type GetPostResponse = z.infer<typeof getPostResponseSchema>

上記のように、zodをベースとしたスキーマ定義を利用することにより、onCallのAPI呼び出しを型安全に行うことができます。
この型定義を用いることでフロントエンドでは以下のように呼び出すことができます。

const functions = getFunctions()
const callableFunction = httpsCallable<GetPostRequest, GetPostResponse>(
  functions,
  functionKeys.getPost
)

ここで、functionKeysは関数名が格納されているオブジェクトで、こちらも自動生成されます。

背景

NestJSでは、nest g res userでCRUDコードが自動生成できるジェネレーターがついています。また、PipeやGuardなどの存在によって、ビジネスロジックの開発に集中することができます。また、コード生成があることによって、どこに何を書けば良いかが明確になるため、プロジェクトのカオス化を未然に防ぐことができます。

Firebaseは、データストアであるFirestoreや、サーバーレス関数を利用できるFirebase Functionsなどを利用することにより、インフラ環境を丸投げできるという点でとても便利なわけですが、開発体験という観点からは、NestJSのようなフレームワークには敵いません。

そうすると、Firebase FunctionsにNestJSを載せるという発想になるわけですが、コールドスタートの問題やOnRequestを利用しなければならなくなるなど問題から、Firebaseを利用するメリットが薄れてしまいます。
そこで、Firebase Functionsを利用しつつ、NestJSのような開発体験を実現したい!と思い、CRUDコードを自動生成するジェネレーターを作成しました。

使用ライブラリ

コードジェネレーターにはplopというライブラリを利用しています。.hbsという拡張子のテンプレートファイルを定義してあげることで、任意のコードを自動生成することができます。したがって、このレポジトリをベースとして自動生成対象をさらに拡張することができます。
https://plopjs.com/

また、DIコンテナとしてtsyringeを利用しています。DIコンテナの使用により、テストのしやすさが向上します。
https://github.com/microsoft/tsyringe

余談:Firebase Hostingを利用したNext.jsのデプロイ

Next.jsのデプロイ先としてはVercelはデファクトスタンダードになっているわけですが、Firebase HostingもEarly preview版ながらNext.jsをホストすることができます。使用するか否かは利用者の皆様にお任せしますが、Vercelとは異なり商用利用する上で、従量課金のみで始められるという点ではメリットがあります。

余談:npmを用いたモノレポ

/sharedをフロントエンドとバックエンドで共有する方法としてモノレポを利用しているわけですが、今はnpmが正式にサポートしています。
https://docs.npmjs.com/cli/v7/using-npm/workspaces

このレポジトリでは、npmを利用したレポジトリ設計を行なっています。もともとyarnを使っていたのですが、firebase hostingの裏側で動いているのがnpmであったため、yarnのワークスペースを認識しないという課題に遭遇したことにより、npmに変更したという経緯があります。

今後やりたいことメモ

  • zod.parseのカプセル化
    controllerでparseするとき、parseの引数がunknownなため、型推論が効かない問題に対応

  • Firebase-Functions-Testの組み込み
    現状でFirebaseFunctions v2に対応していないらしいので、対応次第組み込み。

  • Domain側を充実させて簡易DDDのテンプレート化
    現状はDomainといいつつただのinterfaceなので、ドメインロジックを管理しやすい形にしたい

おわりに

追加機能の要望や使い方に関してのご質問などはTwitter @conaxam までお願いします。

Discussion