EY-Office ブログ

Blitzを再評価してみました

先週のブログRemixはReact界のRuby on Railsか?の中でふれた Jamstack用Fullstack Frameworkを試してみたけど時期尚早だと思ったですが、もう1年6か月も経ったので、再びBlitzRedwoodJSを評価してみる事にしました。今回はBlitzです。

Blitz https://blitzjs.com より

Blitzの特徴

Blitzのホームページには以下の6つのキーワードが書かれています。

Fullstack & Monolithic

JavaScript(TypeScript)を使ったバックエンド、フロントエンドを含むフルスタックのフレームワークです。

  • バックエンドはPrismaを使ったデーターベースアクセスをサポートしています。
  • フロントエンドはReactです、ホック(Hooks)やSuspenseなど新しい技術も使っています
  • 認証(ログイン)機能なども内蔵しています

API Not Required

バックエンドとフロントエンドの通信はフレームワークの中で行われ、アプリ開発者は通信コードを書く必要はありません。前回のRemixと同じですね。

Loose Opinions

フレームワークで使われているライブラリーはプラガブルで置き換える事ができます。たとえばフォームの便利ライブラリーはreact-final-form, react-hook-form, formikから選べます。

Convention over Configuration

Ruby on Rails同様に設定より規約です、Ruby on Railsに大きく影響を受けています。

Easy to Start, Easy to Scale

初心者に優しく、少ないコードでスケールしやすいそうです。

Stability

バージョン1.0(現状はバージョン0.45.4)以降は、安定した定期的リリースサイクルに切り替え、stable、LTS(Long-Term Support)、beta等を用意する予定でだそうです。

前回と同様なジャンケンのアプリを作ってみた

作成手順

1. プロジェクト作成

Ruby on Rails同様にblitz newコマンドでプロジェクトを作ります、いくつかオプションを設定しました。

  • 言語はTypeScript
  • テンプレートはDBアクセスや認証を含むfull
  • フォーム<form>には推奨されているreact-final-formを選択
  • コマンドはnpmを選択(yarnも選べます)
$ npx blitz new jyanken-blitz --language=typescript --template=full --form=react-final-form --npm
2. generate all

Ruby on Railsのrails generate scaffold相当のジェネレーターを使い、モデルの定義を指定し、データーベースやMVCのコードを生成します。

$ npx blitz generate all jyanken computer:int human:int judgment:int
3. 開発開始

開発用サーバーが起動され、webpackを使った開発環境が立ち上がります。

$ npm run dev

フォルダー構成

blitz generate allで生成されたファイル類は以下のようになります。

app/
├── api
├── auth
    ・・・省略・・・
├── core
│   ├── components
│   │   ├── Form.tsx                フォームの共通コンポーネント
│   │   └── LabeledTextField.tsx    上で使われるテキスト入力タグのコンポーネント
 ・・・省略・・・
│   └── layouts
│       └── Layout.tsx              各ページで使われるレイアウトファイル
├── jyankens
│   ├── components
│   │   └── JyankenForm.tsx         作成・更新で使われるフォームのコンポーネント
│   ├── mutations
│   │   ├── createJyanken.ts        バックエンドのJyankenレコード作成関数
│   │   ├── deleteJyanken.ts        バックエンドのJyankenレコード削除関数
│   │   └── updateJyanken.ts        バックエンドのJyankenレコード変更関数
│   └── queries
│       ├── getJyanken.ts           バックエンドのJyanken1レコード取得関数
│       └── getJyankens.ts          バックエンドのJyanken全レコード取得関数
├── pages
    ・・・省略・・・
│   └── jyankens
│       ├── [jyankenId]/edit.tsx    変更ページのコンポーネント
│       ├── [jyankenId].tsx         1件表示ページのコンポーネント
│       ├── index.tsx               一覧表示ページのコンポーネント
│       └── new.tsx                 新規作成ページのコンポーネント
└── users
    ・・・省略・・・

以下の部分は省略しました。

  • 認証関連のコンポーネント、バックエンド
  • データーベース定義(マイグレーション)
  • 設定ファイル

blitz generate allで生成されたコード

blitz generate allで生成されたコードの一部を示します。下のページは一覧表示ページのReactコンポーネントです。

  • usePaginatedQuery()は情報取得系のバックエンド関数を呼び出すホック(Hooks)です
  • usePaginatedQuery()では条件、ソート等を指定できます
  • ③ 一覧表示ページには最初からページング機能があります
  • ④ Ruby on Railsと違いテーブルのカラムに対応する表示やフォームは生成されずに.name決め打ちです
  • ⑤ React18で正式サポートされたSuspenseを使っています、素晴らしい!
app/pages/jyankens/index.tsx
import { Suspense } from "react"
import { Head, Link, usePaginatedQuery, useRouter, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import getJyankens from "app/jyankens/queries/getJyankens"

const ITEMS_PER_PAGE = 100

export const JyankensList = () => {
  const router = useRouter()
  const page = Number(router.query.page) || 0
  const [{ jyankens, hasMore }] = usePaginatedQuery(getJyankens, {  // ←  ①
    orderBy: { id: "asc" },          // ← ②
    skip: ITEMS_PER_PAGE * page,     // ← ③
    take: ITEMS_PER_PAGE,            // ← ③
  })

  const goToPreviousPage = () => router.push({ query: { page: page - 1 } })
  const goToNextPage = () => router.push({ query: { page: page + 1 } })

  return (
    <div>
      <ul>
        {jyankens.map((jyanken) => (
          <li key={jyanken.id}>
            <Link href={Routes.ShowJyankenPage({ jyankenId: jyanken.id })}>
              <a>{jyanken.name}</a>      // ← ④
            </Link>
          </li>
        ))}
      </ul>

      <button disabled={page === 0} onClick={goToPreviousPage}>  // ← ③
        Previous
      </button>
      <button disabled={!hasMore} onClick={goToNextPage}>        // ← ③
        Next
      </button>
    </div>
  )
}

const JyankensPage: BlitzPage = () => {
  return (
    <>
      <Head>
        <title>Jyankens</title>
      </Head>

      <div>
        <p>
          <Link href={Routes.NewJyankenPage()}>
            <a>Create Jyanken</a>
          </Link>
        </p>

        <Suspense fallback={<div>Loading...</div>}>   // ←  ⑤
          <JyankensList />
        </Suspense>
      </div>
    </>
  )
}

JyankensPage.authenticate = true
JyankensPage.getLayout = (page) => <Layout>{page}</Layout>

今回修正したコード

Ruby on Railsと同様にblitz generate allで生成されたコードだけではアプリになりませんので、必要なファイルを変更します。

app/pages/jyankens/index.tsx

以下の画像のような、ジャンケン結果の一覧表示ページのReactコンポーネントです。

もとは上で説明したコードです、見た目以外で変更した部分などは

  • .authenticate = false このページはログインしてなくても表示できるようにしています
  • ページング機能は取ってしましました
import { Suspense } from "react"
import { Head, Link, usePaginatedQuery, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import getJyankens from "app/jyankens/queries/getJyankens"

export const JyankensList = () => {
  const [{ jyankens }] = usePaginatedQuery(getJyankens, {
    orderBy: { id: "desc" },
  })

  const teString = ["グー", "チョキ", "パー"]
  const judgmentString = ["引き分け", "勝ち", "負け"]

  const tableStyle: React.CSSProperties = { marginTop: 20, borderCollapse: "collapse" }
  const thStyle: React.CSSProperties = { border: "solid 1px #888", padding: "3px 15px" }
  const tdStyle: React.CSSProperties = {
    border: "solid 1px #888",
    padding: "3px 15px",
    textAlign: "center",
  }
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {jyankens.map((jyanken, ix) => (
          <tr key={ix}>
            <td style={tdStyle}>{teString[jyanken.human]}</td>
            <td style={tdStyle}>{teString[jyanken.computer]}</td>
            <td style={tdStyle}>{judgmentString[jyanken.judgment]}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

const JyankensPage: BlitzPage = () => {
  return (
    <>
      <Head>
        <title>Jyakens</title>
      </Head>

      <div>
        <p>
          <Link href={Routes.NewJyankenPage()}>
            <a>対戦</a>
          </Link>
        </p>

        <Suspense fallback={<div>Loading...</div>}>
          <JyankensList />
        </Suspense>
      </div>
    </>
  )
}

JyankensPage.authenticate = false        // ← ①
JyankensPage.getLayout = (page) => <Layout>{page}</Layout>

export default JyankensPage

app/jyankens/queries/getJyankens.ts

バックエンドのJyanken全レコード取得関数も少し変更しています。

  • resolver.authorize()をコメントアウトする事でこの関数がログインしてなくても動作するように設定しました
  • PrismaのfindManyメソッドで条件にあう全レコードを取得
import { paginate, resolver } from "blitz"
import db, { Prisma } from "db"

interface GetJyankensInput
  extends Pick<Prisma.JyankenFindManyArgs, "where" | "orderBy" | "skip" | "take"> {}

export default resolver.pipe(
  // resolver.authorize(),           // ← ①
  async ({ where, orderBy, skip = 0, take = 100 }: GetJyankensInput) => {
    // TODO: in multi-tenant app, you must add validation to ensure correct tenant
    const {
      items: jyankens,
      hasMore,
      nextPage,
      count,
    } = await paginate({
      skip,
      take,
      count: () => db.jyanken.count({ where }),   // ← ②
      query: (paginateArgs) => db.jyanken.findMany(
        { ...paginateArgs, where, orderBy }),     // ← ③
    })

    return {
      jyankens,
      nextPage,
      hasMore,
      count,
    }
  }
)

app/pages/jyankens/new.tsx

以下の画像のような、ジャンケンを行う新規作成ページのReactコンポーネントです。

今回は、blitz generate allで生成されたコードを尊重しジャンケンページは、結果一覧ページは別にしました。

  • useMutation()は情報更新系のバックエンド関数を呼び出すホック(Hooks)です
  • ② ジャンケン実行後は上のジャンケン結果の一覧表示ページに移動します
  • ③ このページもログインしてなくても表示できるように設定しています
import { Link, useRouter, useMutation, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import createJyanken from "app/jyankens/mutations/createJyanken"
import { JyankenForm, FORM_ERROR } from "app/jyankens/components/JyankenForm"

const NewJyankenPage: BlitzPage = () => {
  const router = useRouter()
  const [createJyakenMutation] = useMutation(createJyanken)  // ← ①

  return (
    <div>
      <h1>ジャンケンポン</h1>

      <JyankenForm
        submitText="ポン"
        onSubmit={async (values) => {
          try {
            await createJyakenMutation(values)
            router.push(Routes.JyankensPage())  // ← ②
          } catch (error: any) {
            console.error(error)
            return {
              [FORM_ERROR]: error.toString(),
            }
          }
        }}
      />

      <p>
        <Link href={Routes.JyankensPage()}>
          <a>戻る</a>
        </Link>
      </p>
    </div>
  )
}

NewJyankenPage.authenticate = false   // ← ③
NewJyankenPage.getLayout = (page) => <Layout title={"Create New Jyaken"}>{page}</Layout>

export default NewJyankenPage

app/jyankens/components/JyankenForm.tsx

ジャンケンを行うフォームのReactコンポーネントです。

import { Form, FormProps } from "app/core/components/Form"
import { z } from "zod"
export { FORM_ERROR } from "app/core/components/Form"
import { Field } from "react-final-form"

export function JyankenForm<S extends z.ZodType<any, any>>(props: FormProps<S>) {
  return (
    <Form<S> {...props}>
      <div>
        <label>
          <Field name="human" component="input" type="radio" value="0" />  // ← ①
          グー
        </label>
        <label>
          <Field name="human" component="input" type="radio" value="1" />  // ← ①
          チョキ
        </label>
        <label>
          <Field name="human" component="input" type="radio" value="2" />  // ← ①
          パー
        </label>
      </div>
    </Form>
  )
}

app/jyankens/mutations/createJyanken.ts

バックエンドのJyankenレコード作成関数も少し修正しました。

  • ① Blitzはフォーム入力バリデーションにはZodを使っています。ここでは0,1,2という文字のみ有効です
  • ② バリデーションの実行
  • ③ コメントアウトする事で、ログインしてなくても動作するように設定しました
  • Prismaのcreateメソッドでレコードを作成
import { resolver } from "blitz"
import db from "db"
import { z } from "zod"

const CreateJyanken = z.object({  // ← ①
  human: z.enum(["0", "1", "2"]),
})

export default resolver.pipe(
  resolver.zod(CreateJyanken),    // ← ②
  // resolver.authorize(),        // ← ③
  async (input) => {
    const human = Number(input.human)
    const computer = Math.floor(Math.random() * 3)
    const judgment = (computer - human + 3) % 3

    const jyaken = await db.jyanken.create(
      { data: { human, computer, judgment } })      // ← ④

    return jyaken
  }
)

通信

Blitzのフロントエンドとバックエンド間の通信は、REST でも GraphQLでもなく、その中間のような方式です。

  • GraphQLのような言語は使っていない
  • POSTリクエストのみを利用(GraphQL風)
  • 機能名はURLで指定 (REST風)
  • データ、パラメーターはJSON

getJyankens

Jyanken全レコード取得関数

  • リクエスト
    • params: パラメーター
      • ここでは、SELECT文のORDER BY id DESCを指定しています
POST http://localhost:3000/api/rpc/getJyankens

送信データ: {"params":{"orderBy":{"id":"desc"}},"meta":{}}
  • レスポンス
    • result: 結果テータ
    • error: エラーメッセージ
    • meta: データの型情報など
      • createdAt/updatedAtは文字列ですがDate型を表します
{
  "result": {
    "jyankens": [
      {
        "id": 5,
        "createdAt": "2022-07-14T02:06:41.131Z",
        "updatedAt": "2022-07-14T02:06:41.131Z",
        "computer": 2,
        "human": 2,
        "judgment": 0
      },
      {
        "id": 4,
        "createdAt": "2022-07-13T07:49:43.666Z",
        "updatedAt": "2022-07-13T07:49:43.666Z",
        "computer": 1,
        "human": 1,
        "judgment": 0
      },
      ・・・省略・・・
    ],
    "nextPage": null,
    "hasMore": false,
    "count": 5
  },
  "error": null,
  "meta": {
    "result": {
      "values": {
        "jyankens.5.createdAt": [
          "Date"
        ],
        "jyankens.5.updatedAt": [
          "Date"
        ],
        ・・・省略・・・
      }
    }
  }
}

createJyanken

バックエンドのJyankenレコード作成関数

  • リクエスト
    • params: パラメーター
      • ここでは、人間が選択した手を送っています
POST http://localhost:3000/api/rpc/createJyanken

送信データ:  {"params":{"human":"2"},"meta":{}}
  • レスポンスは省略

まとめ

Blitzは完成度高い、フルスタックのフレームワークです。最初から認証(ログイン)を内蔵していて、実用的なアプリが短期間で作成できそうです。使っている技術はNext.js, Prisma, Zodなどメジャーなものです。

Ruby on Railsのようなジェネレーター(generator)があり、Rails同様に効率良くアプリが作れます、ただし生成されるコードにはRDBのカラムに対応する表示・入力等は無く、完成度はRailsに比べると低いです。

ただし、バックエンドのサーバーとフロントエンドとの通信は独自方式なので、サービス(アプリ)が発展しバックエンド側を強化するさいのネックにならないのか私は気になります。また現在のドキュメントは弱く、今回のサンプルコードを作るさいにも困りました。

次回はRedwoodJSです。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ