EY-Office ブログ

RedwoodJSを再評価してみました。そしてRemix, Bliztとの比較

Blitzを再評価してみましたに続き、今回はRedwoodJSを評価しました。

RedwoodJSは下の画像にあるように、スタートアップ企業向けのフレームワークで、将来も使えるアーキテクチャを直ぐ使える形で提供しています。

RedwoodJS https://github.com/redwoodjs/redwood/blob/main/packages/create-redwood-app/README.md より

使っている技術は、以下のようにメジャーなものです。

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

作成手順

1. プロジェクト作成

redwood-appでプロジェクトを作成します。当然TypeScriptを指定、npmではなくyarnが推奨されています。

$ yarn create redwood-app --typescript ./jyanken-redwood
2. DBマイグレーション

Ruby on Rails(Blizt)とは違い、api/db/schema.prismaファイルにモデル定義を書きmigrateコマンドでRDBにテーブル作成します。

$ yarn redwood prisma migrate dev

今回のモデル定義

model Jyanken {
  id         Int      @id @default(autoincrement())
  createdAt  DateTime @default(now())
  computer   Int
  human      Int
  judgment   Int
}
3. Scaffoldジェネレーター

ScaffoldジェネレーターはRuby on Rails(Blizt)とは違い、上のモデル定義を元にMVC(React + GraphQLサーバー)のコードを生成します。またBlitzと違いテーブルのカラムに対応する表示やフォームが生成されます、Ruby on Railsと同じですね。

$ yarn rredwood generate scaffold Jyanken
4. 開発開始

GraphQLサーバー、とフロントエンド開発サーバー(webpack)が起動されます。

$ yarn redwood dev

フォルダー構成

Scaffoldジェネレーターが生成したファイル類は以下のようになります。

Remix,Bliztとは違い、バックエンドとフロントエンドのフォルダーが明確にわかれています。

.
├── README.md
├── api                                       バックエンド・フォルダー
│   ├── db
│   │   ・・・省略・・・
│   │   └── schema.prisma                     Prisma設定ファイル
│   │   ・・・省略・・・
│   ├── src
│   │   ├── directives
│   │   │   ・・・省略・・・                     認証関連のコードなど
│   │   ├── functions
│   │   │   └── graphql.ts                    GraphQLサーバーのコード
│   │   ├── graphql
│   │   │   └── jyankens.sdl.ts               GraphQL定義ファイル
│   │   ├── lib
│   │   │   ・・・省略・・・                     GraphQLサーバー用ライブラリー
│   │   └── services
│   │       └── jyankens
│   │           ├── jyankens.scenarios.ts     ジャンケン用バックエンドのテストデータ
│   │           ├── jyankens.test.ts          ジャンケン用バックエンドのテストコード
│   │           └── jyankens.ts               ジャンケン用処理、RDBアクセスコード
│   └── types
│       └── graphql.d.ts                      バックエンド用型ファイル
│           ・・・省略・・・                           (GraphQL定義ファイルから自動生成)
├── web                                       フロントエンド・フォルダー
│   │       ・・・省略・・・
│   ├── src                                   フロントエンドのソースコード
│   │   ├── App.tsx                           Reactメインコード
│   │   ├── Routes.tsx                        ルーティング定義
│   │   ├── components
│   │   │   └── Jyanken
│   │   │       ├── EditJyankenCell
│   │   │       │   └── EditJyankenCell.tsx   ジャンケン編集Cell
│   │   │       ├── Jyanken
│   │   │       │   └── Jyanken.tsx           ジャンケン表示画面
│   │   │       ├── JyankenCell
│   │   │       │   └── JyankenCell.tsx       ジャンケン表示Cell
│   │   │       ├── JyankenForm
│   │   │       │   └── JyankenForm.tsx       ジャンケン・フォーム
│   │   │       ├── Jyankens
│   │   │       │   └── Jyankens.tsx          ジャンケン一覧画面
│   │   │       ├── JyankensCell
│   │   │       │   └── JyankensCell.tsx      ジャンケン一覧Cell
│   │   │       └── NewJyanken
│   │   │           └── NewJyanken.tsx        ジャンケン新規作成画面
│   │   ├── index.css
│   │   ├── index.html
│   │   ├── layouts
│   │   │   └── JyankensLayout
│   │   │       └── JyankensLayout.tsx       ジャンケン画面のレイアウト
│   │   ├── pages
│   │   │   │    ・・・省略・・・
│   │   │   ├── HomePage
│   │   │   │   ├── HomePage.stories.tsx     トップページ・Storybook定義
│   │   │   │   ├── HomePage.test.tsx        トップページ・テストコード
│   │   │   │   └── HomePage.tsx             トップページ・コンポーネント
│   │   │   ├── Jyanken
│   │   │   │   ├── EditJyankenPage
│   │   │   │   │   └── EditJyankenPage.tsx  ジャンケン編集・コンポーネント
│   │   │   │   ├── JyankenPage
│   │   │   │   │   └── JyankenPage.tsx      ジャンケン表示・コンポーネント
│   │   │   │   ├── JyankensPage
│   │   │   │   │   └── JyankensPage.tsx     ジャンケン一覧・コンポーネント
│   │   │   │   └── NewJyankenPage
│   │   │   │       └── NewJyankenPage.tsx   ジャンケン新規作成・コンポーネント
│   │   │   └── NotFoundPage
│   │   │       └── NotFoundPage.tsx
│   │   └── scaffold.css
│   └── types
│       └── graphql.d.ts                     フロントエンド用型ファイル
                                                    (GraphQL定義ファイルから自動生成)

コードの説明

api/src/graphql/jyankens.sdl.ts

モデル定義からCRUD用のGraphQLの定義が生成されます。型定義にはCreate/Update用の型も生成されているのは現実できですね。

  • ① CreateJyankenInputからは、human, judgmentはモデルで生成するので削除しました
export const schema = gql`
  type Jyanken {
    id: Int!
    createdAt: DateTime!
    computer: Int!
    human: Int!
    judgment: Int!
  }

  type Query {
    jyankens: [Jyanken!]! @requireAuth
    jyanken(id: Int!): Jyanken @requireAuth
  }

  input CreateJyankenInput {
    human: Int!                       // ← ①
  }

  input UpdateJyankenInput {
    computer: Int
    human: Int
    judgment: Int
  }

  type Mutation {
    createJyanken(input: CreateJyankenInput!): Jyanken! @requireAuth
    updateJyanken(id: Int!, input: UpdateJyankenInput!): Jyanken! @requireAuth
    deleteJyanken(id: Int!): Jyanken! @requireAuth
  }
`
api/src/services/jyankens/jyankens.ts

バックエンドのGraphQL APIの実装部分で、RDBアクセスなどのコードです。今回はcreateJyankenのみ変更しました。

  • ① コンピューターの手、勝敗を計算します
  • ② Prismaのcreate関数でデーターベースにレコードを作成しています
import type { QueryResolvers, MutationResolvers } from 'types/graphql'

import { db } from 'src/lib/db'

export const jyankens: QueryResolvers['jyankens'] = () => {
  return db.jyanken.findMany({orderBy: {id: "desc"}})
}

export const jyanken: QueryResolvers['jyanken'] = ({ id }) => {
  return db.jyanken.findUnique({
    where: { id },
  })
}

export const createJyanken: MutationResolvers['createJyanken'] = ({
  input,
}) => {
  const human = input.human                         // ← ①
  const computer = Math.floor(Math.random() * 3)
  const judgment = (computer - human + 3) % 3

  return db.jyanken.create({                        // ←  ②
    data: {human, computer, judgment},
  })
}

export const updateJyanken: MutationResolvers['updateJyanken'] = ({
  id,
  input,
}) => {
  return db.jyanken.update({
    data: input,
    where: { id },
  })
}

export const deleteJyanken: MutationResolvers['deleteJyanken'] = ({ id }) => {
  return db.jyanken.delete({
    where: { id },
  })
}
web/src/pages/Jyanken/JyankensPage/JyankensPage.tsx

ジャンケンの結果一覧ページ(ルーティング、URLに割付いているページ)、Ruby on RailsのScaffold同様に一覧表には削除・変更へのリンクが生成されますが、今回は変更を消しました。

対戦ボタンは、Scaffoldで作られたNew(新規作成)ボタンを利用しました。

コードはJyankensCellコンポーネントを呼び出しています

import JyankensCell from 'src/components/Jyanken/JyankensCell'

const JyankensPage = () => {
  return <JyankensCell />
}

export default JyankensPage
web/src/layouts/JyankensLayout/JyankensLayout.tsx

Ruby on Rails同様に共通レイアウト・コンポーネントが生成されています。Railsとは異なり、ここにNew(対戦)ボタンがあります。

import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'

type JyankenLayoutProps = {
  children: React.ReactNode
}

const JyankensLayout = ({ children }: JyankenLayoutProps) => {
  return (
    <div className="rw-scaffold">
      <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
      <header className="rw-header">
        <h1 className="rw-heading rw-heading-primary">
          <Link
            to={routes.jyankens()}
            className="rw-link"
          >
            ジャンケン
          </Link>
        </h1>
        <Link
          to={routes.newJyanken()}
          className="rw-button rw-button-green"
        >
          対戦
        </Link>
      </header>
      <main className="rw-main">{children}</main>
    </div>
  )
}

export default JyankensLayout
web/src/components/Jyanken/JyankensCell/JyankensCell.tsx

CellはRedwood独自のデータ取得コンポーネントで宣言的な情報取得を実現しています。

  • ① データ取得用のGraphQL、jyankensクエリを呼び出しています
  • ② ローディング中の表示コンポーネント
  • ③ 取得データが0件の時に表示されるコンポーネント
  • ④ データ取得が失敗した時に表示されるコンポーネント
  • ⑤ データが正常に取得できた時に表示されるコンポーネント
import type { FindJyankens } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import Jyankens from 'src/components/Jyanken/Jyankens'

export const QUERY = gql`
  query FindJyankens {
    jyankens {
      id
      computer
      human
      judgment
    }
  }
`                                                         // ← ①

export const Loading = () => <div>Loading...</div>        // ← ②

export const Empty = () => {                              // ← ③
  return (
    <span></span>
  )
}

export const Failure = ({ error }: CellFailureProps) => ( // ← ④
  <div className="rw-cell-error">{error.message}</div>
)

export const Success = ({ jyankens }: CellSuccessProps<FindJyankens>) => {
  return <Jyankens jyankens={jyankens} />                 // ← ⑤
}
web/src/components/Jyanken/Jyankens/Jyankens.tsx

データが正常に取得できた時に表示されるコンポーネントです。

  • ① 削除用GraphQL、deleteJyankenミューテーション(mutation)呼び出し
    • 引数はid
  • ② JyankensListがジャンケン結果一覧表示コンポーネント
  • ③ 削除用ミューテーションの定義ホック
    • onCompleted: 正常終了時の処理
    • onError: エラー時の処理
    • refetchQueries: 再表示用GraphQLクエリー
    • awaitRefetchQueries: 再表示を待つ
  • ④ 削除リンク・クリック時の処理
  • ⑤ ジャンケン結果一覧表示JSX
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'

import { QUERY } from 'src/components/Jyanken/JyankensCell'

const DELETE_JYANKEN_MUTATION = gql`
  mutation DeleteJyankenMutation($id: Int!) {
    deleteJyanken(id: $id) {
      id
    }
  }
`                                                                    // ← ①

const JyankensList = ({ jyankens }) => {                             // ← ②
  const [deleteJyanken] = useMutation(DELETE_JYANKEN_MUTATION, {     // ← ③
    onCompleted: () => {
      toast.success('Jyanken deleted')
    },
    onError: (error) => {
      toast.error(error.message)
    },
    refetchQueries: [{ query: QUERY }],
    awaitRefetchQueries: true,
  })

  const onDeleteClick = (id) => {                                    // ← ④
    if (confirm('Are you sure you want to delete jyanken ' + id + '?')) {
      deleteJyanken({ variables: { id } })
    }
  }

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

  return (
    <div className="rw-segment rw-table-wrapper-responsive">        // ← ⑤
      <table className="rw-table">
        <thead>
          <tr>
            <th>あなた</th>
            <th>コンピュター</th>
            <th>勝敗</th>
            <th>&nbsp;</th>
          </tr>
        </thead>
        <tbody>
          {jyankens.map((jyanken) => (
            <tr key={jyanken.id}>
              <td>{teString[jyanken.human]}</td>
              <td>{teString[jyanken.computer]}</td>
              <td>{judgmentString[jyanken.judgment]}</td>
              <td>
                <nav className="rw-table-actions">
                  <button
                    type="button"
                    title={'Delete jyanken ' + jyanken.id}
                    className="rw-button rw-button-small rw-button-red"
                    onClick={() => onDeleteClick(jyanken.id)}
                  >
                    削除
                  </button>
                </nav>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

export default JyankensList
web/src/pages/Jyanken/NewJyankenPage/NewJyankenPage.tsx

ジャンケン対戦ページ(ルーティング、URLに割付いているページ)のコンポーネント。ジャンケンの選択はラジオボタンで上手く行かなかったのでセレクトにしました。

コードはNewJyankenコンポーネントを呼び出しています

import NewJyanken from 'src/components/Jyanken/NewJyanken'

const NewJyankenPage = () => {
  return <NewJyanken />
}

export default NewJyankenPage
web/src/components/Jyanken/NewJyanken/NewJyanken.tsx

ジャンケン対戦ページのコンポーネント

  • ① 新規作成GraphQL、createJyankenミューテーション(mutation)呼び出し
    • 引数inputは{human: 人間の手}オブジェクト
  • ② JyankensListがジャンケン結果一覧表示コンポーネント
  • ③ 新規作成ミューテーションの定義ホック
    • onCompleted: 正常終了時の処理、ジャンケンの結果一覧ページへ遷移
    • onError: エラー時の処理
  • ④ ポン(もとはSave)ボタン・クリック時の処理
    • inputにフォームからPOSTされた値をcreateJyankenミューテーションに渡しています
  • ⑤ ジャンケン対戦ページ表示JSX
    • フォーム本体はJyankenFormコンポーネントです
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import JyankenForm from 'src/components/Jyanken/JyankenForm'

const CREATE_JYANKEN_MUTATION = gql`
  mutation CreateJyankenMutation($input: CreateJyankenInput!) {
    createJyanken(input: $input) {
      id
    }
  }
`                                                             // ← ①

const NewJyanken = () => {                                    // ← ②
  const [createJyanken, { loading, error }] = useMutation(    // ← ③
    CREATE_JYANKEN_MUTATION, {
    onCompleted: () => {
      toast.success('Jyanken created')
      navigate(routes.jyankens())
    },
    onError: (error) => {
      toast.error(error.message)
    },
  })

  const onSave = (input) => {                                 // ← ④
    createJyanken({ variables: { input } })
  }

  return (                                                    // ← ⑤
    <div className="rw-segment">
      <header className="rw-segment-header">
        <h2 className="rw-heading rw-heading-secondary">ジャンケン ポン</h2>
      </header>
      <div className="rw-segment-main">
        <JyankenForm onSave={onSave} loading={loading} error={error} />
      </div>
    </div>
  )
}

export default NewJyanken
web/src/components/Jyanken/JyankenForm/JyankenForm.tsx

ジャンケン対戦フォームのコンポーネント。Formから得られる値をInt型にするためにセレクトタグを使っています(ラジオボタンではStringしか戻せないようです?)。

  • ① ジャンケン対戦フォームのコンポーネント
  • ② submit(ポン)ボタン。クリック時に呼び出される関数
    • onSaveプロパティーで渡された関数にフォームの値(オブジェクト)を渡しています
  • ③ Formタグ用Redwoodコンポーネント
  • ④ フォームのエラー表示Redwoodコンポーネント
  • ⑤ セレクトタグ用Redwoodコンポーネント
    • validationにvalueAsNumber: trueを指定するとセレクトの結果はInt(数値)になります
  • ⑥ フィールドのエラー表示Redwoodコンポーネント
  • ⑦ Submitタグ用Redwoodコンポーネント
import {
  Form,
  FormError,
  FieldError,
  Submit,
  SelectField,
} from '@redwoodjs/forms'

const JyankenForm = (props) => {                                        // ← ①
  const onSubmit = (data) => {                                          // ← ②
    props.onSave(data, props?.jyanken?.id)
  }

  return (
    <div className="rw-form-wrapper">
      <Form onSubmit={onSubmit} error={props.error}>                    // ← ③
        <FormError                                                      // ← ④
          error={props.error}
          wrapperClassName="rw-form-error-wrapper"
          titleClassName="rw-form-error-title"
          listClassName="rw-form-error-list"
        />

        <SelectField name="human" validation={ {valueAsNumber: true} }> // ← ⑤
          <option value={0}>グー</option>
          <option value={1}>チョキ</option>
          <option value={2}>パー</option>
        </SelectField>

        <FieldError name="human" className="rw-field-error" />          // ← ⑥

        <div className="rw-button-group">
          <Submit                                                       // ← ⑦
            disabled={props.loading}
            className="rw-button rw-button-blue"
          >
            ポン
          </Submit>
        </div>
      </Form>
    </div>
  )
}

export default JyankenForm

まとめ

コード解説でわかるように、RedwoodJSはGraphQLを中心にすえたフロントエンド、バックエンドを含むフルスタックのフレームワークです。そのために生成されるコードはBlitzに比べるとコードは多く、GraphQLを理解する必要あります。

以前ブログに書いたGraphQLの良さはデータ定義が明確な事かも・・・に書いたように長くメンテする大規模なサービスを作るにはGraphQLは役に立つと思います。ただし、RedwoodJSを使うとScaffoldがGraphQLを含めコードを作ってくれるので、最初からGraphQLの深い理解は必要ないのかもしれません(どこかでGraphQLの理解は必要ですよ!)。

また、コードを読んで気になったのはTypeScriptを使っているのに、型が使われていない箇所があり開発しづらい面がありました。もともとがJavaScriptベースで作られていた事も関連するのかもしれませんね、ここは将来に期待しましょう。

Remix, Blizt, RedwoodJSの比較

項目RemixBliztRedwoodJS
FrontendReactReact(Next.js)React
Backend独自独自ApolloGraphQL
DatabasePrizmaPrizmaPrizma
通信REST独自GraphQL
認証機能なし *1ありあり
Static Site Generatorなしありなし
適応規模
公式ページRemixBliztRedwoodJS
EY-Officeブログ2022/07/062022/07/15このブログ
  • *1 : remix-authなどのnpmライブラリーを使うと実現できます

人気

Remix, Blizt, RedwoodJSの人気はどうなんでしょうか? Remix, Bliztは一般的な単語なのでGoogle Trendsでの比較は難しいので、npm trendsで過去3年のnpmのダウンロード数を比較してみました。

Remixの人気は急上昇ですね😊。BliztとRedwoodJSは競っている時期もありましたが現在はRedwoodJSの方が人気があるようです。

感想

個人的な感想ですが、小規模なアプリ(サービス)ならRemix、最初から大規模なサービスならRedwoodJSを使うのが良いかなと思います。Bliztを勧めない理由はバックエンドの拡張性に疑問があるからです。
ただし、SSG(Static Site Generator)、SSR(Server Side Rendering)が非常に重要なるサービスでは、Next.jsを使っているBliztを選択する価値があるかもしれませんね。

RedwoodJSはGraphQL規格のバックエンドを用意すれば良いので、サービスが大規模・複数になってバックエンドを作り直してもフロントエンド側はそのまま使えます(バックエンドのservicesコードも再利用できるかもしれません)。 また最初からテストツールやStorybookのようなツールを内蔵している事も評価できると思います。
さらに、スマフォのネイティブ・クライアントを用意することになっても、バックエンドがGraphQLなので問題なく使えますね!

- about -

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