RedwoodJSのチュートリアルをやってみる
RedwoodJSは、React/GraphQL/Prismaを中心に構成されたフルスタックなWebフレームワーク。
自分はReactとGraphQLが好きで、これらを使ったフレームワークということでかなり興味があったのでチュートリアルをやってみながら、所感を記録していきます。
第1章
まずは環境構築、雛形作成から。
nodeはv16.17.0(LTS)を使うようにしました。
(一部バージョンだとyarn create redwood-app
が動かないかも?自分はv16.7.0だと動きませんでした)
以下を実行でTypeScript化されたアプリケーションの雛形が生成されます。
yarn create redwood-app --ts ./redwoodblog
生成された雛形は、Webフロントエンドを扱う「web」とバックエンドのAPIを扱う「api」の2つのyarn workspaceに分割されています。
ページの生成
ページの生成には以下のコマンドを使用します。
yarn redwood generate page home /
ページコンポーネント、ストーリーブック、テスト用のファイルが生成されます。
yarn redwood generate page xxx
でXxxPage
という名前のファイルがそれぞれ生成されるようです。
ルーティングとリンク
ルーティングはweb/src/Routes.tsx
で定義します。
Routes.tsxではそれぞれのページコンポーネントをimportしなくてもフレームワーク側で自動的にimportしてくれるようです。
Routes.tsxがimport宣言まみれになるのを防ぐためらしいですが、個人的にはこのような暗黙的な挙動はあまり好みじゃないなと思いました。
あと、コンポーネントをimportしないせいで出てくるTSコンパイラのエラーを消す方法がわからなくて困っています。。
リンクはフレームワークから提供されているLinkコンポーネントとroutesオブジェクトを使用します。
多分、yarn redwood generate page xxx
でxxx
という名前のルートがroutesオブジェクトに自動で生やされるっぽい?
<Link to={routes.home()}>Home</Link>
レイアウト
ヘッダーやフッターなど、ページをまたいで共通なコンポーネントをレイアウトコンポーネントとして扱うのは他のフレームワークを使っていたとしてもよく用いられるやり方だと思います。
RedwoodJSではレイアウトコンポーネントを生成するコマンドも用意されています。
yarn redwood g layout blog
コマンドのgenerateはgと省略できるようです。
生成されたレイアウトコンポーネントは以下のように使います。
const Routes = () => {
return (
<Router>
<Set wrap={BlogLayout}>
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
第2章
ブログを作るのに必要なバックエンドとフロントエンドのデータフェッチ周りを扱う章。
DB
ORMにはPrismaを使用しているので、Postのスキーマを追加します。
ここは普通にPrismaの基本を知っていれば問題ないのでスキップします。
マイグレーションはコマンドが通常のPrismaのコマンドとは異なります。
yarn rw prisma migrate dev
コマンドのredwoodはrwのように省略できるようです。
おなじみのPrisma Studio(ブラウザからDBの中身をみれる・操作できるやつ)も使えます。
yarn rw prisma studio
投稿のscaffold
RedwoodJSはモデルに対してのscaffold機能を持っています。
CRUDなAPIからフロントエンドのフォーム周りのUIまでコマンド一発で動く状態の雛形を生成してくれます。
yarn rw g scaffold post
作成された投稿作成ページと投稿一覧ページ
scaffoldコマンドでは以下のリソースが生成されます。
- ページの作成(web/src/pages/Post配下)
- EditPostPage(投稿編集ページ)
- NewPostPage(投稿作成ページ)
- PostPage(投稿詳細ページ)
- PostsPage(投稿一覧ページ)
- レイアウトの作成
- web/src/layouts/PostsLayout/PostsLayout.tsx(投稿関連のページで使うレイアウトファイル)
- ルーティングの追加
- web/src/Routes.tsxへのルーティングの追加
- Cellの作成(web/src/components/Post配下)
- EditPostCell(編集する投稿を取得するCell)
- PostCell(投稿詳細で表示する投稿を取得するCell)
- PostsCell(すべての投稿を取得するCell)
- コンポーネントの作成(web/src/components/Post配下)
- NewPost(投稿を作成するためのフォームを表示するコンポーネント)
- Post(投稿を表示するコンポーネント)
- PostFormNew(投稿の作成と編集で使われるフォームコンポーネント)
- Posts(すべての投稿を表示するコンポーネント)
- GraphQLスキーマの作成
- api/src/graphql/posts.sdl.ts
- DBに対しての操作を行うバックエンドのサービス層のファイル
- api/src/services/posts/posts.ts
たくさんのリソースが作成されますが、これらはすべてRedwoodJSの考えるベストプラクティスに従った実装を持つものらしいです。
個人的にはコマンド一発でUI、GraphQLスキーマ、DB操作の実装まで生成されるのはすごいと思いました。
Cell
RedwoodJSでは、データフェッチを行うときにCellという概念を使います。
Cellはコンポーネントがデータフェッチするときに発生する、ローディング状態・空レスポンス時の表示・エラー発生時の表示・データフェッチ成功時の表示を処理するための仕組みです。
以下のような実装になります。
import type { FindPosts } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
export const QUERY = gql`
query FindPosts {
posts {
id
title
body
createdAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>No posts yet!</div>
export const Failure = ({ error }: CellFailureProps) => (
<div>Error loading posts: {error.message}</div>
)
export const Success = ({ posts }: CellSuccessProps<FindPosts>) => {
return posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<div>{post.body}</div>
</article>
))
}
RedwoodJSは、QUERY
を受け取って実行し、レスポンスが返ってくるまでの読み込み状態ではLoading
コンポーネントをレンダリング、レスポンスがnull
または[]
(空の配列)だった場合はEmpty
コンポーネントをレンダリング(レスポンスが空かどうかの判定はisEmpty
というライフサイクル関数を別途exportすれば空判定のロジックをカスタマイズできます)、エラー発生時にはFailure
コンポーネントをレンダリング、データフェッチ成功時にはSuccess
コンポーネントをレンダリングします。
これにより、Success
コンポーネントではデータフェッチが終わったあとのことだけ考えれば良くなり、実装がシンプルになります。
データフェッチが終わったあとのことだけ考えれば良いようにするという課題意識はReact本体のSuspenseに似ていますね。
Cellの話からは脱線しますが、RedwoodJSにおいてNext.jsのgetServerSidePropsのようなサーバーサイドレンダリングを行う仕組みは現状ないようです。
SEOマストなサービスでは困る可能性があるなと思いました。
Cellを作成するためのコマンドも用意されています。
yarn rw g cell Articles
このコマンドを実行すると、web/src/components/ArticlesCell配下に以下のリソースが作成されます。
- ArticlesCell.tsx(Cell本体の実装)
- ArticlesCell.test.tsx(テストに使用するファイル)
- ArticlesCell.stories.tsx(ストーリーブックで使用するファイル)
- ArticlesCell.mock.tsx(ストーリーブック、テストでレスポンスをモックするためのファイル)
RedwoodJSはCellの中で記述されたGraphQLクエリからTypeScriptの型を生成するためのコマンドも用意してくれています。
yarn rw dev
で開発サーバーを起動している場合は、ファイルの変更を監視して自動で型生成してくれるみたいです。
yarn rw g types
そして、自分は以下の仕様を知らなかったのですが、GraphQLのクエリでは実行したいクエリに対してエイリアスをつけることができるみたいです(これはRedwoodJS関係なく素のGraphQLの仕様です)。
以下のarticles:
の部分が該当します。
export const QUERY = gql`
query ArticlesQuery {
articles: posts {
id
}
}
`
GraphQLクエリ・ミューテーションとリゾルバのマッピング
RedwoodJSはGraphQLスキーマが書かれた*.sdl.ts
ファイルに対応するservicesから名前が一致するリゾルバ関数を自動でimportしてマッピングしてくれます。
export const schema = gql`
# 省略
type Query {
posts: [Post!]!
post(id: Int!): Post!
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`
import { db } from 'src/lib/db'
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
export const posts: QueryResolvers['posts'] = () => {
return db.post.findMany()
}
export const post: QueryResolvers['post'] = ({ id }) => {
return db.post.findUnique({
where: { id },
})
}
export const createPost: MutationResolvers['createPost'] = ({ input }) => {
return db.post.create({
data: input,
})
}
export const updatePost: MutationResolvers['updatePost'] = ({ id, input }) => {
return db.post.update({
data: input,
where: { id },
})
}
export const deletePost: MutationResolvers['deletePost'] = ({ id }) => {
return db.post.delete({
where: { id },
})
}
サービスに記述されたリゾルバ関数をexportしておくことで別のサービスやAPIから呼び出せるようにしたいというねらいがあるようです。
ルーティングパラメータ
ページのURLに対して動的にパラメータを渡すには以下のような記法が使えます。
:Intでidが数値であることを明示できるようです。
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
リンク側は以下のようになります。
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
Cellでパラメータを動的に受け取ってクエリに使用したいケースがあると思います。
export const QUERY = gql`
query ArticleQuery($id: Int!) {
article: post(id: $id) {
id
title
body
createdAt
}
}
`
そのような場合は、Cellを使う側で以下のようにするとパラメータを渡すことができます。
<ArticleCell id={id} />
第3章
第3章ではフォームの作成を行います。
RedwoodJSはフォームに関するAPIやコンポーネントも提供してくれています。
フォームに関するAPIは、React Hook Formをベースに作られていて、React Hook Formを使ったことがあれば違和感なく使えます。
フォームを含むページは以下のような感じになります。
import {
CreateContactMutation,
CreateContactMutationVariables,
} from 'types/graphql'
import {
Form,
Submit,
SubmitHandler,
TextField,
TextAreaField,
FieldError,
Label,
FormError,
useForm,
} from '@redwoodjs/forms'
import { MetaTags, useMutation } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`
interface FormValues {
name: string
email: string
message: string
}
const ContactPage = () => {
const formMethods = useForm()
const [create, { loading, error }] = useMutation<
CreateContactMutation,
CreateContactMutationVariables
>(CREATE_CONTACT, {
onCompleted: () => {
toast.success('Thank you for your submission!')
formMethods.reset()
},
})
const onSubmit: SubmitHandler<FormValues> = (data) => {
create({ variables: { input: data } })
}
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: 'onBlur' }}
error={error}
formMethods={formMethods}
>
<FormError error={error} wrapperClassName="form-error" />
<Label name="name" errorClassName="error">
Name
</Label>
<TextField
name="name"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="name" className="error" />
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: 'Please enter a valid email address',
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
<Label name="message" errorClassName="error">
Message
</Label>
<TextAreaField
name="message"
validation={{ required: true }}
errorClassName="error"
/>
<FieldError name="message" className="error" />
<Submit disabled={loading}>Save</Submit>
</Form>
</>
)
}
export default ContactPage
GraphQLのインターフェースを生成するためのコマンドは以下のとおりです。
yarn rw g sdl Contact
これを実行すると以下のリソースが作成されます。
- api/src/graphql/contacts.sdl.ts
- api/src/services/contacts/contacts.ts
また、サーバーサイドでのバリデーションを行う仕組みも用意されています。
メールアドレスを検証するバリデーションは以下のようになります。
export const createContact: MutationResolvers['createContact'] = ({
input,
}) => {
validate(input.email, 'email', { email: true })
return db.contact.create({
data: input,
})
}
その他、カスタムバリデーションのロジックを記述するための関数も用意されていました。
第4章
第4章では認証とデプロイを扱います。
認証
RedwoodJSでは多くのサードパーティ認証プロバイダーが使えますが、今回はセルフホストのdbAuthを使用します。
以下のコマンドでdbAuthのセットアップを行います。
yarn rw setup auth dbAuth
認証済みでないと表示できないページを作りたいときは、Privateコンポーネントでラップします。
未ログインの状態でPrivateコンポーネントにラップされたページにアクセスされた場合、unauthenticated
propsで指定されたルートにリダイレクトされます。
const Routes = () => {
return (
<Router>
<Private unauthenticated="home">
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
GraphQLのクエリやミューテーションで、認証済みでないユーザーでも実行可能にする場合は、@skipAuth
ディレクティブをつけることで実現可能です。
type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @requireAuth
}
認証に必要ないくつかのページを生成するためのコマンドも用意されています。
以下のコマンドでログイン、サインアップ、パスワードリセットページのUIが生成されます。
yarn rw g dbAuth
デプロイ
DBのホスティングにはRailwayを、フロントエンドとバックエンドのAPIのホスティングにはNetlifyを使います。
Railwayは初めて使ったのですが、ログインすらなしでPostgresのインスタンスを作成できて感動しました。
ただ、アカウントを作成しない場合、データベースが 1 日後に削除されるという制約があるようです。
ホスティングプロバイダーごとのデプロイの足回りをセットアップしてくれるコマンドが用意されているので使いましょう。
yarn rw setup deploy netlify
これでNetlifyの場合は、リポジトリと連携してデフォルトの設定のままデプロイするだけでサイトが動きました(環境変数は追加する必要があります)。
第5章
この章ではRedwoodJSにおけるStorybookとテストの扱いを学びます。
Storybook
RedwoodJSには最初からStorybookが使えるように組み込まれています。以下のコマンドでStorybookを起動できます。
yarn rw storybook
テスト
以下のコマンドでテストを実行できます。
yarn rw test
デフォルトで、ファイルの変更を監視してくれて、変更があるたびにテストを実行してくれます。
第6章
この章では、今まで出てきた知識を使ってRedwood Wayで機能を一つ開発していく章になります。
すごくざっくりですが、以下のような流れでした。
- コンポーネントは
yarn rw g component XXX
で作成 - Storybookを使って、APIに依存せずUIだけ先行して開発
- Cellを使ってコンポーネントの状態ごとの実装を用意
- コンポーネントのテストを記述
- Prismaスキーマの変更
- GraphQLスキーマの追加とサービスの実装(
yarn rw g sdl XXX
) - サービスのテストを記述
また、この章で初めて出てきたコマンドとして
yarn rw console
があります。
これは、ターミナルからRedwoodのパッケージ各種がimportされた状態のNodeコンソールを操作できる機能で、DBに対してのクエリなどを実行することができます。
> db.comment.findMany()
[
{
id: 1,
name: 'Rob',
body: 'The first real comment!',
postId: 1,
createdAt: 2020-12-08T23:45:10.641Z
},
{
id: 2,
name: 'Tom',
body: 'Here is another comment',
postId: 1,
createdAt: 2020-12-08T23:46:10.641Z
}
]
第7章
この章ではロールベースのアクセス制御を扱います。認可にあたる実装の部分です。
PrismaのスキーマのUserモデルにrolesというカラムを追加します。
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
roles String @default("moderator")
}
URLに対してアクセス可能なロールを割り当ててページへのアクセス制御を行う場合は、Privateコンポーネントのpropsでアクセス可能なロールを指定します。
<Private unauthenticated="home" roles="admin">
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
ユーザーの操作に応じてロールによる制御を行いたい場合は、以下のようにできます。
const Comment = ({ comment }) => {
const { hasRole } = useAuth()
const moderate = () => {
if (confirm('Are you sure?')) {
// TODO: delete comment
}
}
return (
<div className="bg-gray-200 p-8 rounded-lg relative">
<header className="flex justify-between">
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
{formattedDate(comment.createdAt)}
</time>
</header>
<p className="text-sm mt-2">{comment.body}</p>
{hasRole('moderator') && (
<button
type="button"
onClick={moderate}
className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
>
Delete
</button>
)}
</div>
)
}
バックエンドではフレームワークから提供されているrequireAuth
という関数を使うことで認可が実現できます。
import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth'
// ...
export const deleteComment = ({ id }) => {
requireAuth({ roles: 'moderator' })
return db.comment.delete({
where: { id },
})
}
ここまででチュートリアルは完了です!
チュートリアルでは特に書かれていなかったけど気になったこと
スタイリングについて
スタイリングについてはRedwoodJSは特に意見を持ってないようです。
チュートリアルではTailwindCSSを使っていますが、他のCSS in JSのライブラリやCSS Modulesなども使えると思います。
個人的にはコロケーションが実現しやすいCSS in JSかCSS Modulesが相性良さそうだなと思いました。
感想
RedwoodJSは小規模から大規模まで対応できるフルスタックなWebフレームワークだと思いました。
用意されているコマンド各種を使いこなすことでスピーディーな開発が実現できそうです。
ただ、フロントエンドとバックエンドでチーム分けされているような組織にはなじまないような気がします。
フロントエンドもバックエンドも対応できる開発者が使って初めて真価が発揮されると思ったので、TypeScriptが使えるフルスタックエンジニアがいる組織にはマッチしそうな感じです。
そして、スピーディーな開発を実現しつつも、テストは決しておざなりにしない姿勢が好印象でした。
yarn rw g component xxx
などでコンポーネントを生成するコマンドを実行するとストーリーブックのファイルとテストのファイルも自動生成されるので、Redwood Wayに従って開発していればテストカバレッジも低くなりにくいようになっています。
Cellの仕組みも本当によくできていると感じていて、特に現場ではAPIのレスポンスが空だったときのUIの表示考慮が漏れていてバグチケットを切られるみたいなことは結構あるんじゃないかと思います。
RedwoodはCellによってコンポーネントのデータフェッチにおける状態ごとの表示内容を宣言的に記述できるのでそのようなバグも起こりづらいのが素晴らしいなと思いました。
まだまだ発展途上なフレームワークなので足りない点もありますが(個人的にはSSRがほしい)、十分商用環境で使えるレベルには達しているなと感じました。
ReactのWebフレームワークというとNext.jsとRemixの2つが挙げられることが多いですが、RedwoodJSもここに並ぶようになってほしいです。
次なにか作るときは採用を検討したいです。