Blitzを再評価してみましたに続き、今回はRedwoodJSを評価しました。
RedwoodJSは下の画像にあるように、スタートアップ企業向けのフレームワークで、将来も使えるアーキテクチャを直ぐ使える形で提供しています。
https://github.com/redwoodjs/redwood/blob/main/packages/create-redwood-app/README.md より
使っている技術は、以下のようにメジャーなものです。
- React フロントエンドNo.1ライブラリー
- GraphQL 定義が明確で拡張性のあるAPI定義とサーバー GraphQLの良さは・・・
- Prisma JavaScript用の有名ORM
- TypeScript 今や常識、型を導入した良いJavaScript
- Jest Facebook(Meta)が開発したテストツール
- Storybook Reactコンポーネントのカタログツール ・・・Storybookを使ってみたら良いツールだった
前回と同様なジャンケンのアプリを作ってみた
作成手順
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> </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: 人間の手}
オブジェクト
- 引数inputは
- ② 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
(数値)になります
- validationに
- ⑥ フィールドのエラー表示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の比較
項目 | Remix | Blizt | RedwoodJS |
---|---|---|---|
Frontend | React | React(Next.js) | React |
Backend | 独自 | 独自 | ApolloGraphQL |
Database | Prizma | Prizma | Prizma |
通信 | REST | 独自 | GraphQL |
認証機能 | なし *1 | あり | あり |
Static Site Generator | なし | あり | なし |
適応規模 | 小 | 中 | 大 |
公式ページ | Remix | Blizt | RedwoodJS |
EY-Officeブログ | 2022/07/06 | 2022/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なので問題なく使えますね!