Closed9

RedwoodJSのチュートリアルをやってみる

Eringi_V3Eringi_V3

RedwoodJSは、React/GraphQL/Prismaを中心に構成されたフルスタックなWebフレームワーク。
自分はReactとGraphQLが好きで、これらを使ったフレームワークということでかなり興味があったのでチュートリアルをやってみながら、所感を記録していきます。
https://redwoodjs.com/

Eringi_V3Eringi_V3

第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 xxxXxxPageという名前のファイルがそれぞれ生成されるようです。

ルーティングとリンク

ルーティングはweb/src/Routes.tsxで定義します。
Routes.tsxではそれぞれのページコンポーネントをimportしなくてもフレームワーク側で自動的にimportしてくれるようです。
Routes.tsxがimport宣言まみれになるのを防ぐためらしいですが、個人的にはこのような暗黙的な挙動はあまり好みじゃないなと思いました。
あと、コンポーネントをimportしないせいで出てくるTSコンパイラのエラーを消す方法がわからなくて困っています。。

リンクはフレームワークから提供されているLinkコンポーネントとroutesオブジェクトを使用します。
多分、yarn redwood generate page xxxxxxという名前のルートが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>
  )
}
Eringi_V3Eringi_V3

第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してマッピングしてくれます。

api/src/graphql/posts.sdl.ts
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
  }
`
api/src/services/posts/posts.ts
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} />
Eringi_V3Eringi_V3

第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,
  })
}

その他、カスタムバリデーションのロジックを記述するための関数も用意されていました。

Eringi_V3Eringi_V3

第4章

第4章では認証とデプロイを扱います。

認証

RedwoodJSでは多くのサードパーティ認証プロバイダーが使えますが、今回はセルフホストのdbAuthを使用します。
以下のコマンドでdbAuthのセットアップを行います。

yarn rw setup auth dbAuth

認証済みでないと表示できないページを作りたいときは、Privateコンポーネントでラップします。
未ログインの状態でPrivateコンポーネントにラップされたページにアクセスされた場合、unauthenticatedpropsで指定されたルートにリダイレクトされます。

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 日後に削除されるという制約があるようです。
https://railway.app/

ホスティングプロバイダーごとのデプロイの足回りをセットアップしてくれるコマンドが用意されているので使いましょう。

yarn rw setup deploy netlify

これでNetlifyの場合は、リポジトリと連携してデフォルトの設定のままデプロイするだけでサイトが動きました(環境変数は追加する必要があります)。

Eringi_V3Eringi_V3

第5章

この章ではRedwoodJSにおけるStorybookとテストの扱いを学びます。

Storybook

RedwoodJSには最初からStorybookが使えるように組み込まれています。以下のコマンドでStorybookを起動できます。

yarn rw storybook

テスト

以下のコマンドでテストを実行できます。

yarn rw test

デフォルトで、ファイルの変更を監視してくれて、変更があるたびにテストを実行してくれます。

Eringi_V3Eringi_V3

第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
  }
]
Eringi_V3Eringi_V3

第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 },
  })
}
Eringi_V3Eringi_V3

ここまででチュートリアルは完了です!

チュートリアルでは特に書かれていなかったけど気になったこと

スタイリングについて

スタイリングについては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もここに並ぶようになってほしいです。
次なにか作るときは採用を検討したいです。

このスクラップは2022/09/08にクローズされました