- 投稿日:2020-09-27T23:48:49+09:00
ReactNative での環境切り替え時メモ
環境の切り替え対象
- APIのエンドポイントなどの切り替え
- react-native-config を使用する
- Release, Debug ビルドでの設定の切り替え、Firebase の設定ファイル (GoogleService-Info.plist)
- iOS, Android のビルド時の設定変更の機能を使用して、切り替える
react-native-config の導入
$ yarn add react-native-config $ react-native link react-native-config $ (cd ios; pod install)iOSの環境の設定切り替え
- こちら を参考にし、Target のコピー、Scheme 追加
- 6 の pre-action step
PROJECT_DIR
の .env を書き換えるため、ローカル実行時はENVFILE=.env.local
を使用するなど工夫するreact-run-ios --scheme "作成したTarget名"
で起動するGoogleService-Info.plist の配置
- 各 Target の名前のディレクトリを作成しその配下に置く
- XCode で GoogleService-Info.plist を選択 -> TargetMembership で読み込む Target を指定する
注意点
- build の Info タブで React のチェックを外さないようにする
- テスト等、コピー元のTargetにチェックが入っている場合、
react-run-ios
時にそちらの app が起動してしまう- こちら を参考に Podfile の書き換えを行う
Android の環境の設定切り替え
- ProductFlavorの設定をしておく
- こちらを参考に設定
- 作成した variant 名でを指定 (例:
react-run-android --variant=DevDebug
)バージョン/ビルドバージョンの書き換え用のコードの用意
yarn run set:version {次のバージョン番号} {次のビルド番号}
のような形で起動setVersion.jsconst fs = require('fs'); const { execSync } = require('child_process'); const version = process.argv[2]; if (!version) { throw 'version が指定されていません'; } const build = process.argv[3]; if (!build) { throw 'build が指定されていません'; } console.log(`build: ${version}`); // ファイル文字列置換 const versionFiles = [ { name: 'src/constants.js', regex: /export const appVersion.*/, replace: `export const appVersion = "${version}";` }, { name: 'android/app/build.gradle', regex: / versionName .*/, replace: ` versionName "${version}"` }, { name: 'android/app/src/main/AndroidManifest.xml', regex: / android:versionName=.*/, replace: ` android:versionName="${version}">` } ]; const buildFiles = [ { name: 'android/app/build.gradle', regex: / versionCode .*/, replace: ` versionCode ${build}` }, { name: 'android/app/src/main/AndroidManifest.xml', regex: / android:versionCode=.*/, replace: ` android:versionCode="${build}"` } ]; console.log("\nreplace version"); for (const file of versionFiles) { const data = fs.readFileSync(file.name, 'utf8'); const target = data.match(file.regex); console.log(` file: ${file.name}`); console.log(` ${target}`); console.log(` -> ${file.replace}\n`); const replaced = data.replace(file.regex, file.replace); fs.writeFileSync(file.name, replaced, 'utf8'); } console.log("\nreplace build"); for (const file of buildFiles) { const data = fs.readFileSync(file.name, 'utf8'); const target = data.match(file.regex); console.log(` file: ${file.name}`); console.log(` ${target}`); console.log(` -> ${file.replace}\n`); const replaced = data.replace(file.regex, file.replace); fs.writeFileSync(file.name, replaced, 'utf8'); } // ios 設定変更 console.log("\nreplace version in xcode\n"); execSync(`cd ios/ && agvtool new-marketing-version ${version}`, {stdio: 'inherit'}); console.log("\nreplace build in xcode\n"); execSync(`cd ios/ && agvtool new-version -all ${build}`, {stdio: 'inherit'});参考
https://github.com/luggit/react-native-config
https://dev.classmethod.jp/articles/xcode-build-environment-adding-scheme/
https://gist.github.com/jacks205/45b2721bf2a2b912b1c130aef2572820#ios-instructions
- 投稿日:2020-09-27T18:09:07+09:00
Redux ExampleのTodoListをReact Native(expo)に置き換えて解説-ToggleTodo編
Redux ExampleのTodoListをReact Native(expo)に置き換えて解説のToggleTodo編です。
AddTodo編は以下です。
Redux ExampleのTodoListをReact Native(expo)に置き換えて解説AddTodo編注:僕は掛け出しエンジニアであり、自分の勉強としての投稿という面もあるので、もしミスや勘違い、ベストプラクティスではない、等がありましたら、コメントしていただけると幸いです。
TODOに属性を追加
まずは、todoにcomplete属性を追加してtodoが完了済みなのかactive状態なのかを判定できるようにします。初期値はfalseにしておきます。
reducers/todos.jsconst todoReducers = (state = [], action) => { switch(action.type){ case 'ADD_TODO': return [ ...state, { id: action.id, text: action.text, completed: false //追加 } ] default: return state } } export default todoReducersTodoのcompleted属性がtrueかfalseによって見た目を変えたいので、TodoListコンポーネントを修正します。
components/TodoList.jsclass TodoList extends Component { render() { return ( <View> <FlatList data={this.props.todos} renderItem={({ item }) => ( <View style={styles.todoList}> <Text style={{ textDecorationLine: item.completed ? "line-through" : "none", }} //追加↑ > {item.text} </Text> </View> )} keyExtractor={(item) => item.id.toString()} /> </View> ); } }ここで、動作確認のため、reducers/todos.jsのcompleted属性をtrueにしてみましょう。todoに斜線が引かれていると思います。
Actionからcompleted属性を操作する
次に、上記でやったtrue,falseの操作をActionを経由して操作できるようにしましょう。
まずはActionCreatorを作成します。どのtodoに横線を引くかを判別するためにidを取得します。
src/actions/index.jslet nextTodoId = 0 export const addTodo = text => ({ type: 'ADD_TODO', id: nextTodoId++, text }) //追加 export const toggleTodo = (id) => { return { type: "TOGGLE_TODO", id, }; };reducersも変更します。
reducers/todos.jsconst todoReducers = (state = [], action) => { switch (action.type) { case "ADD_TODO": return [ ...state, { id: action.id, text: action.text, completed: true, }, ]; case "TOGGLE_TODO": //追加 return state.map((todo) => todo.id === action.id ? { ...todo, completed: !todo.completed } : todo ); default: return state; } }; export default todoReducers;やっていることとしては、stateに保存されている全てのtodoについて、それぞれのidとActionCreatorに渡された、横線を引きたいtodoのidを比べて、一致したらそのtodoのcompleted属性を逆転させるというものです。
ここで、例の如くApp.jsから動作確認をしてみましょう
App.jsに以下を追加してみましょう。App.jsimport { addTodo, toggleTodo } from './actions' store.dispatch(addTodo('Hello React!')) store.dispatch(toggleTodo(0))画面をみてみると斜線が引かれたtodoが追加されているはずです。
Todoをクリックしてcompleted属性を操作できるようにする
それでは、画面上からtodoの完了、未完了の操作ができるようにしましょう。mapDispatchToPropsを作成します。
これはcomponentで(今回で言えばTodoListで)dispatchをpropsとして渡せるものです。こうすることで、component側ではthis.props.onTodoClick(id)の形で先ほどApp.jsでやったdispatch(toggleTodo(0))みたいなことができるようになります。
containers/VisibleTodoList.jsimport { connect } from "react-redux"; import TodoList from "../components/TodoList"; import { toggleTodo } from "../actions"; //追加 const mapStateToProps = (state) => { return { todos: state.todoReducers }; }; //追加 const mapDispatchToProps = (dispatch) => { return { onTodoClick: (id) => { dispatch(toggleTodo(id)); }, }; }; //追加 const VisibleTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList); export default VisibleTodoList;これでTodoListの方でonTodoClickが使えるようになったので、追加してみましょう。
components/TodoList.jsreturn ( <View> <FlatList data={this.props.todos} renderItem={({ item }) => ( <View style={styles.todoList}> <Text onPress={() => this.props.onTodoClick(item.id)} style={{ textDecorationLine: item.completed ? "line-through" : "none", }} > {item.text} </Text> </View> )} keyExtractor={(item) => item.id.toString()} /> </View> );これでtodoをクリックすると該当のtodoに斜線が引かれます。
ToggleTodo編は完成です!
お手元のシミュレーターでお試しください。
ここまでのソースコードはGitHubに上げていますのでご参考ください。次回は表示するTodoをcompleted属性によって切り替える「FilterTodo」機能を実装していきます。
参考
- 投稿日:2020-09-27T16:25:45+09:00
next.jsでdotenvで環境変数を設定するのは間違い!たった3分で環境変数を設定する方法
概要
・next.jsでは「.env.local」ファイルで環境変数を設定できる。
・クライアント側でも環境変数を設定したいときは接頭語に「NEXT_PUBLIC_」をつける
・開発環境は「.env.development」、本番環境は「.env.production」でそれぞれの環境変数を設定できる環境変数を設定する(サーバー編)
next.jsでは簡単に環境変数を変更する仕組みがあります。
ルートディレクトリに「.env.local」ファイルを置き、その中で定義した環境変数はアプリの中で使うことができます。たとえば、開発環境と本番環境で異なるデータベースを使いますよね。そんな時に「.env.local」ファイルに設定すれば環境変数をアプリ内で利用できます。
env.localDB_HOST=localhost DB_USER=root DB_PASSWORD=root環境変数の使用.jsexport async function getServerSideProps(context) { console.log(process.env.DB_HOST) //コンソールに「localhost」を出力(サーバー側) return {props: {}} }環境変数に他の環境変数を設定したい場合
また、「.env.local」で設定した環境変数を別の環境変数で使うには、「$」を使います。
たとえば、ホスト名にポート番号を追加したいときには以下のように定義します。env.localPORT=3000 HOST=http://localhost:$PORTindex.jsexport async function getServerSideProps(context) { console.log(process.env.HOST) //コンソールに「http:localhost:3000」を出力(サーバー側) return {props: {}} }環境変数を設定する(クライアント編)
先ほどの、「.env.local」で設定した環境変数はnode.jsのみで適用されます。そのため、ブラウザ側で動くプログラムでは使うことができません、
クライアント側でも環境変数を使いたいときにはどうすればいいのでしょうか?この問題を解決するのが環境変数の接頭語の「NEXT_PUBLIC_」です。環境変数の接頭語に「NEXT_PUBLIC_」をつければ、クライアント側でも使うことができます。
これにより、ブラウザ側、サーバー側どちらでも環境変数を利用できるのです。env.localNEXT_PUBLIC_TEST=client-serverindex.jsexport async function getServerSideProps(context) { console.log(process.env.NEXT_PUBLIC_TEST) //コンソールに「client-server」を出力(サーバー側) return {props: {}} } const index = () => { return <p>{process.env.NEXT_PUBLIC_TEST}</p> //画面に「client-server」を出力(クライアント側) } export default index環境によって読み込むファイルを変更する方法
環境ごとに読み込む環境変数を変更したい場合があります。
たとえば、開発環境では開発用のDBを使うけど、本番環境では本番用のDBを使いたいなど環境ごとにDBを切り替えたい場面などです。そんな時は環境ごとのenvファイルを用意します。以下の4種類でそれぞれの環境変数を設定できます。
・.env:全環境共通の環境変数
・.env.development:開発環境(next dev)の環境変数
・.env.production:本番環境(next start)の環境変数
・.env.local:デフォルトの環境変数(すべての環境変数を上書き)これの環境変数の優先順位は以下の通りです。
①.env.local
②.env.development/.env.production
③.env基本的に、「.env」にデフォルトで全ての環境変数を設定して、「.env.development」、「.env.production」で上書きするのがいいのではないでしょうか?
参考
- 投稿日:2020-09-27T12:20:41+09:00
クリーンアーキテクチャ、react-query、redux-toolkitな環境を作る
はじめに
以下のような考えを満たすような環境を作ってみました。
- フロントエンドの環境でもクリーンアーキテクチャのようなことをしたい
- 非同期処理はモダンなreact-queryで書きたい
- 今まで慣れ親しんだreduxも入れときたい
コードはこちらにあります
https://github.com/pokotyan/clean-architecture-front利用パッケージ
利用した主なパッケージなどは以下の通りです。
Context
diコンテナーとして利用class-transformer、class-validator
モデルの生成、バリデーションに利用@reduxjs/toolkit
reduxの導入に利用。グローバルなstateの管理をするnormalizr
reduxのストアの正規化に利用react-query、Suspense、ErrorBoundary
非同期処理、エラーハンドリングに利用react-router@v6
ルーティングに利用。v6のインストールは現状 @next を指定する必要があります。
Suspenceをつかった非同期処理を行う場合、v5以前のものでは相性が悪いらしく、v6を利用しました。参考作ったアプリ
サンプルとして作ったものは、以下のようなダミーのデータを元に一覧ページ、詳細ページが閲覧できるものです。
データ
src/domain/api/blog/get/index.tsconst blogsData = [ { id: 123, user: { id: 1, name: "Tom", }, title: "引越し", body: "引越ししました", createdAt: dayjs(), comments: [ { id: 324, body: "どこに?", createdAt: dayjs().add(3, "hour"), user: { id: 2, name: "Bob", }, }, { id: 325, body: "今度、家いかせて", createdAt: dayjs().add(5, "hour"), user: { id: 3, name: "Sam", }, }, ], }, ];一覧ページ
詳細ページ
ディレクトリ構成
ディレクトリ構成はこんな感じです。
├── src │ ├── App.tsx │ ├── ErrorBoundary.tsx // エラーハンドリングのためのコンポーネント │ ├── components │ │ ├── containers // データとコンポーネントの受け渡しをする層 │ │ │ ├── BlogDetail.tsx │ │ │ └── BlogList.tsx │ │ ├── oraganisms // propsでもらったデータをただ表示するコンポーネント │ │ │ ├── BlogDetail.tsx │ │ │ └── BlogList.tsx │ │ └── pages // ページのコンポーネント │ │ └── Blog.tsx │ ├── domain │ │ ├── api // apiを叩いてデータを取って来る層 │ │ │ ├── blog │ │ │ └── schema.ts │ │ ├── di // diをする層 │ │ │ ├── blog.ts │ │ │ └── index.ts │ │ ├── model // ドメインロジックを詰めるモデル層 │ │ │ └── blog │ │ ├── repository // reduxのstore情報を元にしてモデルを返す │ │ │ ├── blog │ │ │ └── index.ts │ │ └── usecase // ユースケース層 │ │ └── blog │ ├── features // reduxのaction、reducer │ │ └── blog │ │ ├── action.ts │ │ └── reducer.ts │ ├── hooks // カスタムフック │ │ ├── useDI.ts │ │ └── useQueryWithSuspense.ts │ ├── index.tsx │ └── store.ts // reduxのstore生成ここからは順を追って、それぞれのディレクトリの処理を見ていきます。
apiからのデータ取得、正規化、reduxのストアへの格納
getBlogs関数は、apiからデータを取得してきたていの関数です。モックのデータをPromiseに包んで返します。
取得したデータはreduxのストアに格納します。
reduxのストアにはapiから取得したデータをそのまま格納するのではなく、正規化した値を入れるのがいいとされているのでそのようにしました。参考正規化した値を入れることで、データのネスト構造が浅く保てるので、reducerで更新処理がわかりやすくなります。
正規化にはnormalizrを利用しました。src/domain/api/blog/get/index.tsimport { normalize } from "normalizr"; import dayjs from "dayjs"; import { NormalizedBlogs, blogSchema, NormalizedBlog, Entities, } from "./schema"; import { store } from "../../../../store"; import blogAction from "../../../../features/blog/action"; const blogsData = [ { id: 123, user: { id: 1, name: "Tom", }, title: "引越し", body: "引越ししました", createdAt: dayjs(), comments: [ { id: 324, body: "どこに?", createdAt: dayjs().add(3, "hour"), user: { id: 2, name: "Bob", }, }, { id: 325, body: "今度、家いかせて", createdAt: dayjs().add(5, "hour"), user: { id: 3, name: "Sam", }, }, ], }, ]; export const getBlogs = (): Promise<NormalizedBlogs> => { const normalizedBlogs = normalize< NormalizedBlog, Entities, { blogs: number[] } >({ blogs: blogsData }, { blogs: [blogSchema] }); store.dispatch(blogAction.set(normalizedBlogs)); return Promise.resolve(normalizedBlogs); };こんな感じで、apiから取得してきた想定のブログのデータをnormalizeで正規化し、
const normalizedBlogs = normalize< NormalizedBlog, Entities, { blogs: number[] } >({ blogs: blogsData }, { blogs: [blogSchema] });正規化した値をストアに格納しています。
store.dispatch(blogAction.set(normalizedBlogs));normalizrで正規化したデータはこんな感じになって、ストアに格納されます。apiからとってきたデータはネストした構造でしたが、ネストしていた部分は該当データへのid参照のみになっていることがわかります。
{ "entities": { "users": { "1": { "id": 1, "name": "Tom" }, "2": { "id": 2, "name": "Bob" }, "3": { "id": 3, "name": "Sam" } }, "comments": { "324": { "id": 324, "body": "どこに?", "createdAt": "2020-09-27T04:59:27.926Z", "user": 2 }, "325": { "id": 325, "body": "今度、家いかせて", "createdAt": "2020-09-27T06:59:27.927Z", "user": 3 } }, "blogs": { "123": { "id": 123, "user": 1, "title": "引越し", "body": "引越ししました", "createdAt": "2020-09-27T01:59:27.926Z", "comments": [ 324, 325 ] } } }, "result": { "blogs": [ 123 ] } }normalizrのスキーマ定義
normalizrで利用するスキーマや正規化、非正規化した後の型は自身で書く必要があります。
今回はこんな感じの型を用意しました。src/domain/api/blog/get/schema.tsimport { schema, NormalizedSchema } from "normalizr"; type ListEntities< K extends string | number | symbol, T, U extends string > = { [k in U]: Record<K, T>; }; // apiレスポンス export type BlogResponse = { id: number; user: { id: number; name: string; }; title: string; comments: { id: number; body: string; user: { id: number; name: string; }; }[]; }; export type BlogsResponse = BlogResponse[]; // normalizrで使うスキーマ export const userSchema = new schema.Entity<NormalizedUser>("users"); export const commentSchema = new schema.Entity<Comment>("comments", { user: userSchema, }); export const blogSchema = new schema.Entity<NormalizedBlog>("blogs", { user: userSchema, comments: [commentSchema], }); // normalizeされた後の型定義 export type NormalizedBlogs = NormalizedSchema<Entities, { blogs: number[] }>; export type Entities = BlogEntities & UserEntities & CommentEntities; export interface NormalizedBlog { id: number; user: number; title: string; comments: number[]; } export type BlogsStore = Record<number, NormalizedBlog>; export type BlogEntities = ListEntities<number, NormalizedBlog, "blogs">; export interface NormalizedComment { id: number; body: string; user: number; } export type CommentsStore = Record<number, NormalizedComment>; export type CommentEntities = ListEntities< number, NormalizedComment, "comments" >; export interface NormalizedUser { id: number; name: string; } export type UsersStore = Record<number, NormalizedUser>; export type UserEntities = ListEntities<number, NormalizedUser, "users">;クリーンアーキテクチャ、DI
ドメインロジックをになっていく各層を作成していきます。
usecase層はreduxのストアに格納されている正規化したデータを引数としてもらい、そのデータを元にいろんなユースケースを実現していくイメージです。│ ├── domain │ │ ├── di // diをする層 │ │ │ ├── blog.ts │ │ │ └── index.ts │ │ ├── model // ドメインロジックを詰めるモデル層 │ │ │ └── blog │ │ ├── repository // reduxのstore情報を元にしてモデルを返す │ │ │ ├── blog │ │ │ └── index.ts │ │ └── usecase // ユースケース層 │ │ └── blogusecase
usecaseに生えているメソッドが実際のコンポーネントから叩かれるメソッドになります。
こんな感じで、Repository
のinterfaceを満たすものをinjectionするようになっています。
findAll、findOneはBlogのモデルを返すようになっています。src/domain/usecase/blog/index.tsimport { injectable, inject } from "tsyringe"; import type { Repository } from "../../repository"; import { Blog } from "../../model/blog/blog"; import { RootState } from "../../../store"; @injectable() export default class BlogUseCase { constructor(@inject("BlogRepository") private repository: Repository) {} findAll(store: RootState["blog"]) { return this.repository.findAll<Blog>(store); } findOne(blogId: number, store: RootState["blog"]) { return this.repository.findOne<Blog>(blogId, store); } }Repositoryのinterfaceはこんな感じです。これを満たすものであればusecaseにdiすることができるようになります。
src/domain/repository/index.tsexport interface Repository { findAll<T>(store: any): Promise<T[]>; findOne<T>(id: number, store: any): Promise<T>; }今回はサンプルのアプリがシンプルすぎるので、usecaseのメソッド名とrepositoryのメソッド名が被ってますが、イメージ的にはユースケース層はいろんなrepository層をinjectionして、各モデルが協調して一つの処理を行うイメージです。
今回の例では、storeの値を引数に受け取り、モデルを返しているだけです。repository
repositoryはreduxのストアの値を引数に、モデルを返します。モデルの生成はinjectionしているFactory層が担っています。
ストアの値は正規化されているので、このrepositoryの層でdenormalize
し、元のapiレスポンスの形に戻しています。
そのレスポンスをthis.factory
に渡すことでモデルが返ってきます。import { injectable, inject } from "tsyringe"; import { denormalize } from "normalizr"; import { BlogResponse, blogSchema } from "../../api/blog/get/schema"; import type { Factory } from "../../model/blog/factory"; import type { Repository } from ".."; import type { RootState } from "../../../store"; @injectable() export default class BlogRepository implements Repository { constructor(@inject("BlogFactory") private factory: Factory) {} findAll<T>(store: RootState["blog"]): Promise<T[]> { const denormalizedBlogs = denormalize( store.result.blogs, [blogSchema], store.entities ); return Promise.all( denormalizedBlogs.map((blog: BlogResponse) => this.factory.create(blog)) ); } findOne<T>(id: number, store: RootState["blog"]): Promise<T> { const denormalizedBlog: BlogResponse = denormalize( id, blogSchema, store.entities ); return this.factory.create(denormalizedBlog); } }Factoryのinterfaceはこんな感じです。
このinterfaceを満たすものであれば、repositoryにdiできます。src/domain/model/blog/factory.tsexport interface Factory { create<T>(data: any): Promise<T>; }factory
factoryは引数でもらった非正規化したストアの値を元に、モデルを返します。
モデルの生成にclass-transformer、モデルのバリデーションにclass-validatorを使っています。src/domain/model/blog/factory.tsimport { injectable } from "tsyringe"; import { plainToClass } from "class-transformer"; import { validate } from "class-validator"; import { Blog } from "./blog"; import type { BlogResponse } from "../../api/blog/get/schema"; export interface Factory { create<T>(data: any): Promise<T>; } @injectable() export class BlogFactory { async create(denormalizedBlog: BlogResponse): Promise<Blog> { const model = plainToClass(Blog, denormalizedBlog); const errors = await validate(model); if (errors.length) { throw new Error(`モデルの作成に失敗しました。\n${errors}`); } return model; } }model
class-transformer、class-validatorを用いて生成するモデルです。
このモデルをコンポーネント上で扱って、画面を作っていくことになります。src/domain/model/blog/blog.tsimport { Type } from "class-transformer"; import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator"; import dayjs from "dayjs"; import { Author } from "./user"; import Comment from "./comment"; export class Blog { @IsInt() id: number; @Type(() => Author) @ValidateNested() user: Author; @IsString() title: string; @IsString() body: string; @Type(() => Comment) @ValidateNested() comments: Comment[]; @IsDateString() createdAt: Date; get postDate(): string { return dayjs(this.createdAt).format("MMMM D, YYYY"); } }src/domain/model/blog/comment.tsimport { Type } from "class-transformer"; import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator"; import dayjs from "dayjs"; import { Commenter } from "./user"; export default class Comment { @IsInt() id: number; @IsString() body: string; @Type(() => Commenter) @ValidateNested() user: Commenter; @IsDateString() createdAt: Date; get postDate(): string { return dayjs(this.createdAt).format("MMMM D, YYYY"); } }src/domain/model/blog/user.tsimport { IsInt, IsString } from "class-validator"; class User { @IsInt() id: number; @IsString() name: string; get initial(): string { return this.name[0]; } } export class Author extends User { @IsInt() id: number; @IsString() name: string; } export class Commenter extends User { @IsInt() id: number; @IsString() name: string; }DI
作成したリポジトリ、ファクトリをDIして、ユースケースのインスタンスを生成する層です。
src/domain/di/blog.tsimport { container } from "tsyringe"; import BlogUseCase from "../usecase/blog"; import { BlogFactory } from "../model/blog/factory"; import BlogRepository from "../repository/blog"; import type { Repository } from "../repository"; export const NewBlogUseCase = () => { container.register("BlogFactory", { useClass: BlogFactory }); container.register<Repository>("BlogRepository", { useClass: BlogRepository, }); return container.resolve(BlogUseCase); };ユースケースを利用したい際に毎回
NewBlogUseCase
を呼び出すのは手間です。
なので、ここで生成したユースケースのインスタンスはReactのContextに流し込み、利用したいコンポーネント上で取り出せるようにします。ユースケースのインスタンスをまとめたエントリーポイントを作ります。
src/domain/di/index.tsimport { NewBlogUseCase } from "./blog"; export default { usecase: { blog: NewBlogUseCase(), }, };上記のエントリーポイントを値としたContextを作り、カスタムフックとして利用できるようにします。
src/hooks/useDI.tsimport React, { useContext } from "react"; import di from "../domain/di"; export const DIContext = React.createContext(di); export const useDIContext = () => { return useContext(DIContext); };src/index.tsx(抜粋)import React from "react"; import App from "./App"; import { DIContext } from "./hooks/useDI"; ReactDOM.render( <DIContext.Provider value={di}> <App /> </DIContext.Provider> document.getElementById("root") );使う側ではこのようにしてユースケースを利用できます。
const di = useDIContext(); di.usecase.blog.findAll(data)非同期処理
非同期処理には、react-query、Suspense、ErrorBoundaryを利用しました。
react-queryのオプションでグローバルにsuspenceを有効化しておきます。
src/index.tsx(抜粋)import React from "react"; import ReactDOM from "react-dom"; import { ReactQueryConfig, ReactQueryConfigProvider } from "react-query"; import App from "./App"; const queryConfig: ReactQueryConfig = { shared: { suspense: true, }, }; ReactDOM.render( <ReactQueryConfigProvider config={queryConfig}> <App /> </ReactQueryConfigProvider> document.getElementById("root") );こんな感じでuseQueryを利用します。
useDIContextで取得してきたユースケースをuseQueryのfetcher引数に渡してデータを取得します。src/components/containers/BlogDetail.tsximport React, { FC } from "react"; import { getBlogs } from "../../domain/api/blog/get"; import { useQueryWithSuspense } from "../../hooks/useQueryWithSuspense"; import BlogList from "../oraganisms/BlogList"; import BlogUseCase from "../../domain/usecase/blog"; import { NormalizedBlogs } from "../../domain/api/blog/get/schema"; import { useDIContext } from "../../hooks/useDI"; const fetchBlogModels = (usecase: BlogUseCase) => { return async (data: NormalizedBlogs) => { return usecase.findAll(data); }; }; const BlogListContainer: FC = () => { const { data: blogsData } = useQueryWithSuspense("getBlogs", getBlogs); const di = useDIContext(); const { data: blogs } = useQueryWithSuspense( [blogsData, "getBlogModels"], fetchBlogModels(di.usecase.blog) ); return <BlogList blogs={blogs} />; }; export default BlogListContainer;useQueryのfetcherの関数を高階関数にしている理由は、当初以下のようにインスタンスのメソッドを素直にfetcherに渡していましたが、
const { data: blogs } = useQueryWithSuspense( [blogsData, "getBlogModels"], di.usecase.blog.findAll );どうやらインスタンスのメソッドを渡すのは無理っぽく、これではエラーになりました。そのため、高階関数を渡すようにしています。
また、
useQueryWithSuspense
は独自に用意したカスタムフックです。useQueryの結果のdataがnullableの型で推論されるのが面倒だったので以下のようなフックを用意し、dataが必ずある状態の型が返って来るようにしました。
このフックはこちらの記事の実装をそのまま利用させていただきました。src/hooks/useQueryWithSuspense.tsimport { useQuery, QueryFunction, QueryKey, QueryResult, QueryConfig, } from "react-query"; type RequireData<T extends { data: unknown }> = T & { data: NonNullable<T["data"]>; }; type UseQueryWithSuspenseResult<T> = RequireData<QueryResult<T, unknown>>; export const useQueryWithSuspense = <T extends unknown>( queryKey: QueryKey, fetcher: QueryFunction<T>, queryConfig?: QueryConfig<T> ): UseQueryWithSuspenseResult<T> => { return useQuery(queryKey, fetcher, queryConfig) as any; };エラーハンドリング
非同期処理などのエラーハンドリング漏れを防ぐためにErrorBoundaryを利用しました。
こんな感じのコンポーネントを用意し、
src/ErrorBoundary.tsximport React, { Component, ErrorInfo, ReactNode } from "react"; export interface Props { children: ReactNode; } export interface State { hasError: boolean; error: Error | null; } export default class ErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { console.log(error, errorInfo); } render(): ReactNode { const { hasError, error } = this.state; const { children } = this.props; if (hasError) { return <h1 style={{ whiteSpace: "pre-wrap" }}>{error?.message}</h1>; } return children; } }エラーハンドリングしたいコンポーネントをErrorBoundaryで包みます。
src/components/pages/Blog.tsximport React, { Suspense } from "react"; import { Route, Routes } from "react-router"; import CircularProgress from "@material-ui/core/CircularProgress"; import BlogList from "../containers/BlogList"; import BlogDetail from "../containers/BlogDetail"; import ErrorBoundary from "../../ErrorBoundary"; function BlogPage() { return ( <ErrorBoundary> <Routes> <Route path="" element={ <Suspense fallback={<CircularProgress />}> <BlogList /> </Suspense> } ></Route> <Route path=":blogId" element={ <Suspense fallback={<CircularProgress />}> <BlogDetail /> </Suspense> } /> </Routes> </ErrorBoundary> ); } export default BlogPage;例として、モデルを生成する際バリデーションに失敗するとErrorをスローするようにしていました。
src/domain/model/blog/factory.ts(抜粋)async create(denormalizedBlog: BlogResponse): Promise<Blog> { const model = plainToClass(Blog, denormalizedBlog); console.log(model); const errors = await validate(model); if (errors.length) { throw new Error(`モデルの作成に失敗しました。\n${errors}`); } return model; }意図的にモックのAPIレスポンスデータを不正な値にしてみます。
すると、こんな感じで、ErrorBoundaryでキャッチできていることがわかります。
ルーティング
ルーティングにはreact-router@v6を利用しました。
react-query、Suspenseの組み合わせで非同期処理をしていくのは今後増えていきそうですが、v5以前のreact-routerではSuspenseとの相性が悪いようで、react-query、Suspenseを使いたいならv6を入れておいた方が後々楽だと思います。参考v6ではv3のときにあったようなルーティングを一箇所にネストして定義するやり方(nested routes)もできますし、v5の時のような各コンポーネントに分散してルーティングを書くこともできます。今回のサンプルアプリではv5の時のような各コンポーネントに分散して書く形で実装しました。
src/App.tsximport React, { useEffect } from "react"; import { Route, Routes, useLocation, useNavigate } from "react-router"; import Blog from "./components/pages/Blog"; import "./App.css"; function App() { const { hash, pathname } = useLocation(); const navigate = useNavigate(); useEffect(() => { if (pathname === "/") { navigate("/blog"); } }, [hash, pathname, navigate]); return ( <Routes> <Route path="blog/*" element={<Blog />} /> </Routes> ); } export default App;src/components/pages/Blog.tsximport React, { Suspense } from "react"; import { Route, Routes } from "react-router"; import CircularProgress from "@material-ui/core/CircularProgress"; import BlogList from "../containers/BlogList"; import BlogDetail from "../containers/BlogDetail"; import ErrorBoundary from "../../ErrorBoundary"; function BlogPage() { return ( <ErrorBoundary> <Routes> <Route path="" element={ <Suspense fallback={<CircularProgress />}> <BlogList /> </Suspense> } ></Route> <Route path=":blogId" element={ <Suspense fallback={<CircularProgress />}> <BlogDetail /> </Suspense> } /> </Routes> </ErrorBoundary> ); } export default BlogPage;Redux toolkit
グローバルなstateの管理には@reduxjs/toolkitを利用しました。
普通にreduxを利用した場合はtypescriptとの相性やaction、reducerなどのボイラープレート的なコード量の多さに辟易していきますが、redux toolkitを利用した場合はそこらへんのめんどくささが結構解消されているため、使いやすく感じてます。(アクションとかは自分で書く必要はなくなるし、tsとの相性もいい)ディレクトリ構造はfeaturesディレクトリのなかにactionとreducerをまとめるようにしています。
├── src │ ├── features // reduxのaction、reducer │ │ └── blog │ │ ├── action.ts │ │ └── reducer.tssrc/features/blog/action.tsimport { NormalizedBlogs } from "../../domain/api/blog/get/schema"; import { RootState } from "../../store"; import { blogSlice } from "./reducer"; const { actions } = blogSlice; export const selectBlog = (state: RootState): NormalizedBlogs => state.blog; export const isEmpty = (state: RootState): boolean => !!Object.keys(state.blog.entities.blogs).length; export default actions;src/features/blog/reducer.tsimport { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { NormalizedBlogs } from "../../domain/api/blog/get/schema"; const initialState: NormalizedBlogs = { entities: { users: {}, comments: {}, blogs: {}, }, result: { blogs: [], }, }; export const blogSlice = createSlice({ name: "blog", initialState, reducers: { set: (state, action: PayloadAction<NormalizedBlogs>): NormalizedBlogs => { return { ...state, ...action.payload }; }, }, }); export default blogSlice.reducer;最後に
今回思いつきで作ってみましたが、実際にこのアーキテクチャで業務コードを書いたことはないため、スケールしていくコードにどれだけ耐えれる設計なのかは実際に試してみようと思いました。
また、normalizrは実際業務で使ったことはなく、初めて使ってみたのですがスキーマなどの型定義が結構めんどくさく、「ストアの正規化ってそこまで恩恵あるのかな??」と懐疑的な気持ちになりました。
- 投稿日:2020-09-27T12:20:41+09:00
クリーンアーキテクチャ、react-query、redux-toolkitなフロントエンドの環境を作る
はじめに
以下のような考えを満たすような環境を作ってみました。
- フロントエンドの環境でもクリーンアーキテクチャのようなことをしたい
- 非同期処理はモダンなreact-queryで書きたい
- 今まで慣れ親しんだreduxも入れときたい
コードはこちらにあります
https://github.com/pokotyan/clean-architecture-front利用パッケージ
利用した主なパッケージなどは以下の通りです。
Context
diコンテナーとして利用class-transformer、class-validator
モデルの生成、バリデーションに利用@reduxjs/toolkit
reduxの導入に利用。グローバルなstateの管理をするnormalizr
reduxのストアの正規化に利用react-query、Suspense、ErrorBoundary
非同期処理、エラーハンドリングに利用react-router@v6
ルーティングに利用。v6のインストールは現状 @next を指定する必要があります。
Suspenceをつかった非同期処理を行う場合、v5以前のものでは相性が悪いらしく、v6を利用しました。参考作ったアプリ
サンプルとして作ったものは、以下のようなダミーのデータを元に一覧ページ、詳細ページが閲覧できるものです。
データ
src/domain/api/blog/get/index.tsconst blogsData = [ { id: 123, user: { id: 1, name: "Tom", }, title: "引越し", body: "引越ししました", createdAt: dayjs(), comments: [ { id: 324, body: "どこに?", createdAt: dayjs().add(3, "hour"), user: { id: 2, name: "Bob", }, }, { id: 325, body: "今度、家いかせて", createdAt: dayjs().add(5, "hour"), user: { id: 3, name: "Sam", }, }, ], }, ];一覧ページ
詳細ページ
ディレクトリ構成
ディレクトリ構成はこんな感じです。
├── src │ ├── App.tsx │ ├── ErrorBoundary.tsx // エラーハンドリングのためのコンポーネント │ ├── components │ │ ├── containers // データとコンポーネントの受け渡しをする層 │ │ │ ├── BlogDetail.tsx │ │ │ └── BlogList.tsx │ │ ├── oraganisms // propsでもらったデータをただ表示するコンポーネント │ │ │ ├── BlogDetail.tsx │ │ │ └── BlogList.tsx │ │ └── pages // ページのコンポーネント │ │ └── Blog.tsx │ ├── domain │ │ ├── api // apiを叩いてデータを取って来る層 │ │ │ ├── blog │ │ │ └── schema.ts │ │ ├── di // diをする層 │ │ │ ├── blog.ts │ │ │ └── index.ts │ │ ├── model // ドメインロジックを詰めるモデル層 │ │ │ └── blog │ │ ├── repository // reduxのstore情報を元にしてモデルを返す │ │ │ ├── blog │ │ │ └── index.ts │ │ └── usecase // ユースケース層 │ │ └── blog │ ├── features // reduxのaction、reducer │ │ └── blog │ │ ├── action.ts │ │ └── reducer.ts │ ├── hooks // カスタムフック │ │ ├── useDI.ts │ │ └── useQueryWithSuspense.ts │ ├── index.tsx │ └── store.ts // reduxのstore生成ここからは順を追って、それぞれのディレクトリの処理を見ていきます。
apiからのデータ取得、正規化、reduxのストアへの格納
getBlogs関数は、apiからデータを取得してきたていの関数です。モックのデータをPromiseに包んで返します。
取得したデータはreduxのストアに格納します。
reduxのストアにはapiから取得したデータをそのまま格納するのではなく、正規化した値を入れるのがいいとされているのでそのようにしました。参考正規化した値を入れることで、データのネスト構造が浅く保てるので、reducerで更新処理がわかりやすくなります。
正規化にはnormalizrを利用しました。src/domain/api/blog/get/index.tsimport { normalize } from "normalizr"; import dayjs from "dayjs"; import { NormalizedBlogs, blogSchema, NormalizedBlog, Entities, } from "./schema"; import { store } from "../../../../store"; import blogAction from "../../../../features/blog/action"; const blogsData = [ { id: 123, user: { id: 1, name: "Tom", }, title: "引越し", body: "引越ししました", createdAt: dayjs(), comments: [ { id: 324, body: "どこに?", createdAt: dayjs().add(3, "hour"), user: { id: 2, name: "Bob", }, }, { id: 325, body: "今度、家いかせて", createdAt: dayjs().add(5, "hour"), user: { id: 3, name: "Sam", }, }, ], }, ]; export const getBlogs = (): Promise<NormalizedBlogs> => { const normalizedBlogs = normalize< NormalizedBlog, Entities, { blogs: number[] } >({ blogs: blogsData }, { blogs: [blogSchema] }); store.dispatch(blogAction.set(normalizedBlogs)); return Promise.resolve(normalizedBlogs); };こんな感じで、apiから取得してきた想定のブログのデータをnormalizeで正規化し、
const normalizedBlogs = normalize< NormalizedBlog, Entities, { blogs: number[] } >({ blogs: blogsData }, { blogs: [blogSchema] });正規化した値をストアに格納しています。
store.dispatch(blogAction.set(normalizedBlogs));normalizrで正規化したデータはこんな感じになって、ストアに格納されます。apiからとってきたデータはネストした構造でしたが、ネストしていた部分は該当データへのid参照のみになっていることがわかります。
{ "entities": { "users": { "1": { "id": 1, "name": "Tom" }, "2": { "id": 2, "name": "Bob" }, "3": { "id": 3, "name": "Sam" } }, "comments": { "324": { "id": 324, "body": "どこに?", "createdAt": "2020-09-27T04:59:27.926Z", "user": 2 }, "325": { "id": 325, "body": "今度、家いかせて", "createdAt": "2020-09-27T06:59:27.927Z", "user": 3 } }, "blogs": { "123": { "id": 123, "user": 1, "title": "引越し", "body": "引越ししました", "createdAt": "2020-09-27T01:59:27.926Z", "comments": [ 324, 325 ] } } }, "result": { "blogs": [ 123 ] } }normalizrのスキーマ定義
normalizrで利用するスキーマや正規化、非正規化した後の型は自身で書く必要があります。
今回はこんな感じの型を用意しました。src/domain/api/blog/get/schema.tsimport { schema, NormalizedSchema } from "normalizr"; type ListEntities< K extends string | number | symbol, T, U extends string > = { [k in U]: Record<K, T>; }; // apiレスポンス export type BlogResponse = { id: number; user: { id: number; name: string; }; title: string; comments: { id: number; body: string; user: { id: number; name: string; }; }[]; }; export type BlogsResponse = BlogResponse[]; // normalizrで使うスキーマ export const userSchema = new schema.Entity<NormalizedUser>("users"); export const commentSchema = new schema.Entity<Comment>("comments", { user: userSchema, }); export const blogSchema = new schema.Entity<NormalizedBlog>("blogs", { user: userSchema, comments: [commentSchema], }); // normalizeされた後の型定義 export type NormalizedBlogs = NormalizedSchema<Entities, { blogs: number[] }>; export type Entities = BlogEntities & UserEntities & CommentEntities; export interface NormalizedBlog { id: number; user: number; title: string; comments: number[]; } export type BlogsStore = Record<number, NormalizedBlog>; export type BlogEntities = ListEntities<number, NormalizedBlog, "blogs">; export interface NormalizedComment { id: number; body: string; user: number; } export type CommentsStore = Record<number, NormalizedComment>; export type CommentEntities = ListEntities< number, NormalizedComment, "comments" >; export interface NormalizedUser { id: number; name: string; } export type UsersStore = Record<number, NormalizedUser>; export type UserEntities = ListEntities<number, NormalizedUser, "users">;クリーンアーキテクチャ、DI
ドメインロジックをになっていく各層を作成していきます。
usecase層はreduxのストアに格納されている正規化したデータを引数としてもらい、そのデータを元にいろんなユースケースを実現していくイメージです。│ ├── domain │ │ ├── di // diをする層 │ │ │ ├── blog.ts │ │ │ └── index.ts │ │ ├── model // ドメインロジックを詰めるモデル層 │ │ │ └── blog │ │ ├── repository // reduxのstore情報を元にしてモデルを返す │ │ │ ├── blog │ │ │ └── index.ts │ │ └── usecase // ユースケース層 │ │ └── blogusecase
usecaseに生えているメソッドが実際のコンポーネントから叩かれるメソッドになります。
こんな感じで、Repository
のinterfaceを満たすものをinjectionするようになっています。
findAll、findOneはBlogのモデルを返すようになっています。src/domain/usecase/blog/index.tsimport { injectable, inject } from "tsyringe"; import type { Repository } from "../../repository"; import { Blog } from "../../model/blog/blog"; import { RootState } from "../../../store"; @injectable() export default class BlogUseCase { constructor(@inject("BlogRepository") private repository: Repository) {} findAll(store: RootState["blog"]) { return this.repository.findAll<Blog>(store); } findOne(blogId: number, store: RootState["blog"]) { return this.repository.findOne<Blog>(blogId, store); } }Repositoryのinterfaceはこんな感じです。これを満たすものであればusecaseにdiすることができるようになります。
src/domain/repository/index.tsexport interface Repository { findAll<T>(store: any): Promise<T[]>; findOne<T>(id: number, store: any): Promise<T>; }今回はサンプルのアプリがシンプルすぎるので、usecaseのメソッド名とrepositoryのメソッド名が被ってますが、イメージ的にはユースケース層はいろんなrepository層をinjectionして、各モデルが協調して一つの処理を行うイメージです。
今回の例では、storeの値を引数に受け取り、モデルを返しているだけです。repository
repositoryはreduxのストアの値を引数に、モデルを返します。モデルの生成はinjectionしているFactory層が担っています。
ストアの値は正規化されているので、このrepositoryの層でdenormalize
し、元のapiレスポンスの形に戻しています。
そのレスポンスをthis.factory
に渡すことでモデルが返ってきます。import { injectable, inject } from "tsyringe"; import { denormalize } from "normalizr"; import { BlogResponse, blogSchema } from "../../api/blog/get/schema"; import type { Factory } from "../../model/blog/factory"; import type { Repository } from ".."; import type { RootState } from "../../../store"; @injectable() export default class BlogRepository implements Repository { constructor(@inject("BlogFactory") private factory: Factory) {} findAll<T>(store: RootState["blog"]): Promise<T[]> { const denormalizedBlogs = denormalize( store.result.blogs, [blogSchema], store.entities ); return Promise.all( denormalizedBlogs.map((blog: BlogResponse) => this.factory.create(blog)) ); } findOne<T>(id: number, store: RootState["blog"]): Promise<T> { const denormalizedBlog: BlogResponse = denormalize( id, blogSchema, store.entities ); return this.factory.create(denormalizedBlog); } }Factoryのinterfaceはこんな感じです。
このinterfaceを満たすものであれば、repositoryにdiできます。src/domain/model/blog/factory.tsexport interface Factory { create<T>(data: any): Promise<T>; }factory
factoryは引数でもらった非正規化したストアの値を元に、モデルを返します。
モデルの生成にclass-transformer、モデルのバリデーションにclass-validatorを使っています。src/domain/model/blog/factory.tsimport { injectable } from "tsyringe"; import { plainToClass } from "class-transformer"; import { validate } from "class-validator"; import { Blog } from "./blog"; import type { BlogResponse } from "../../api/blog/get/schema"; export interface Factory { create<T>(data: any): Promise<T>; } @injectable() export class BlogFactory { async create(denormalizedBlog: BlogResponse): Promise<Blog> { const model = plainToClass(Blog, denormalizedBlog); const errors = await validate(model); if (errors.length) { throw new Error(`モデルの作成に失敗しました。\n${errors}`); } return model; } }model
class-transformer、class-validatorを用いて生成するモデルです。
このモデルをコンポーネント上で扱って、画面を作っていくことになります。src/domain/model/blog/blog.tsimport { Type } from "class-transformer"; import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator"; import dayjs from "dayjs"; import { Author } from "./user"; import Comment from "./comment"; export class Blog { @IsInt() id: number; @Type(() => Author) @ValidateNested() user: Author; @IsString() title: string; @IsString() body: string; @Type(() => Comment) @ValidateNested() comments: Comment[]; @IsDateString() createdAt: Date; get postDate(): string { return dayjs(this.createdAt).format("MMMM D, YYYY"); } }src/domain/model/blog/comment.tsimport { Type } from "class-transformer"; import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator"; import dayjs from "dayjs"; import { Commenter } from "./user"; export default class Comment { @IsInt() id: number; @IsString() body: string; @Type(() => Commenter) @ValidateNested() user: Commenter; @IsDateString() createdAt: Date; get postDate(): string { return dayjs(this.createdAt).format("MMMM D, YYYY"); } }src/domain/model/blog/user.tsimport { IsInt, IsString } from "class-validator"; class User { @IsInt() id: number; @IsString() name: string; get initial(): string { return this.name[0]; } } export class Author extends User { @IsInt() id: number; @IsString() name: string; } export class Commenter extends User { @IsInt() id: number; @IsString() name: string; }DI
作成したリポジトリ、ファクトリをDIして、ユースケースのインスタンスを生成する層です。
src/domain/di/blog.tsimport { container } from "tsyringe"; import BlogUseCase from "../usecase/blog"; import { BlogFactory } from "../model/blog/factory"; import BlogRepository from "../repository/blog"; import type { Repository } from "../repository"; export const NewBlogUseCase = () => { container.register("BlogFactory", { useClass: BlogFactory }); container.register<Repository>("BlogRepository", { useClass: BlogRepository, }); return container.resolve(BlogUseCase); };ユースケースを利用したい際に毎回
NewBlogUseCase
を呼び出すのは手間です。
なので、ここで生成したユースケースのインスタンスはReactのContextに流し込み、利用したいコンポーネント上で取り出せるようにします。ユースケースのインスタンスをまとめたエントリーポイントを作ります。
src/domain/di/index.tsimport { NewBlogUseCase } from "./blog"; export default { usecase: { blog: NewBlogUseCase(), }, };上記のエントリーポイントを値としたContextを作り、カスタムフックとして利用できるようにします。
src/hooks/useDI.tsimport React, { useContext } from "react"; import di from "../domain/di"; export const DIContext = React.createContext(di); export const useDIContext = () => { return useContext(DIContext); };src/index.tsx(抜粋)import React from "react"; import App from "./App"; import { DIContext } from "./hooks/useDI"; ReactDOM.render( <DIContext.Provider value={di}> <App /> </DIContext.Provider> document.getElementById("root") );使う側ではこのようにしてユースケースを利用できます。
const di = useDIContext(); di.usecase.blog.findAll(data)非同期処理
非同期処理には、react-query、Suspenseを利用しました。
react-query
react-queryのオプションでグローバルにsuspenceを有効化しておきます。
src/index.tsx(抜粋)import React from "react"; import ReactDOM from "react-dom"; import { ReactQueryConfig, ReactQueryConfigProvider } from "react-query"; import App from "./App"; const queryConfig: ReactQueryConfig = { shared: { suspense: true, }, }; ReactDOM.render( <ReactQueryConfigProvider config={queryConfig}> <App /> </ReactQueryConfigProvider> document.getElementById("root") );こんな感じでuseQueryを利用します。
useDIContextで取得してきたユースケースをuseQueryのfetcher引数に渡してデータを取得します。src/components/containers/BlogDetail.tsximport React, { FC } from "react"; import { getBlogs } from "../../domain/api/blog/get"; import { useQueryWithSuspense } from "../../hooks/useQueryWithSuspense"; import BlogList from "../oraganisms/BlogList"; import BlogUseCase from "../../domain/usecase/blog"; import { NormalizedBlogs } from "../../domain/api/blog/get/schema"; import { useDIContext } from "../../hooks/useDI"; const fetchBlogModels = (usecase: BlogUseCase) => { return async (data: NormalizedBlogs) => { return usecase.findAll(data); }; }; const BlogListContainer: FC = () => { const { data: blogsData } = useQueryWithSuspense("getBlogs", getBlogs); const di = useDIContext(); const { data: blogs } = useQueryWithSuspense( [blogsData, "getBlogModels"], fetchBlogModels(di.usecase.blog) ); return <BlogList blogs={blogs} />; }; export default BlogListContainer;useQueryのfetcherの関数を高階関数にしている理由は、当初以下のようにインスタンスのメソッドを素直にfetcherに渡していましたが、
const { data: blogs } = useQueryWithSuspense( [blogsData, "getBlogModels"], di.usecase.blog.findAll );どうやらインスタンスのメソッドを渡すのは無理っぽく、これではエラーになりました。そのため、高階関数を渡すようにしています。
また、
useQueryWithSuspense
は独自に用意したカスタムフックです。useQueryの結果のdataがnullableの型で推論されるのが面倒だったので以下のようなフックを用意し、dataが必ずある状態の型が返って来るようにしました。
このフックはこちらの記事の実装をそのまま利用させていただきました。src/hooks/useQueryWithSuspense.tsimport { useQuery, QueryFunction, QueryKey, QueryResult, QueryConfig, } from "react-query"; type RequireData<T extends { data: unknown }> = T & { data: NonNullable<T["data"]>; }; type UseQueryWithSuspenseResult<T> = RequireData<QueryResult<T, unknown>>; export const useQueryWithSuspense = <T extends unknown>( queryKey: QueryKey, fetcher: QueryFunction<T>, queryConfig?: QueryConfig<T> ): UseQueryWithSuspenseResult<T> => { return useQuery(queryKey, fetcher, queryConfig) as any; };Suspense
react-queryで非同期処理を行っているコンポーネントをSuspenseで囲ってあげることで、apiのデータ取得中は
fallback
に指定したコンポーネントが表示されます。ここではグルグル回るスピナーを表示するようにしています。src/components/pages/Blog.tsx(抜粋)import React, { Suspense } from "react"; import CircularProgress from "@material-ui/core/CircularProgress"; import BlogList from "../containers/BlogList"; import BlogDetail from "../containers/BlogDetail"; function BlogPage() { return ( ... <Suspense fallback={<CircularProgress />}> <BlogList /> </Suspense> ... <Suspense fallback={<CircularProgress />}> <BlogDetail /> </Suspense> ... ); }エラーハンドリング
非同期処理などのエラーハンドリング漏れを防ぐためにErrorBoundaryを利用しました。
こんな感じのコンポーネントを用意し、
src/ErrorBoundary.tsximport React, { Component, ErrorInfo, ReactNode } from "react"; export interface Props { children: ReactNode; } export interface State { hasError: boolean; error: Error | null; } export default class ErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { console.log(error, errorInfo); } render(): ReactNode { const { hasError, error } = this.state; const { children } = this.props; if (hasError) { return <h1 style={{ whiteSpace: "pre-wrap" }}>{error?.message}</h1>; } return children; } }エラーハンドリングしたいコンポーネントをErrorBoundaryで包みます。
src/components/pages/Blog.tsximport React, { Suspense } from "react"; import { Route, Routes } from "react-router"; import CircularProgress from "@material-ui/core/CircularProgress"; import BlogList from "../containers/BlogList"; import BlogDetail from "../containers/BlogDetail"; import ErrorBoundary from "../../ErrorBoundary"; function BlogPage() { return ( <ErrorBoundary> <Routes> <Route path="" element={ <Suspense fallback={<CircularProgress />}> <BlogList /> </Suspense> } ></Route> <Route path=":blogId" element={ <Suspense fallback={<CircularProgress />}> <BlogDetail /> </Suspense> } /> </Routes> </ErrorBoundary> ); } export default BlogPage;ErrorBoundaryでエラーハンドリングをする例として、モデルを生成する際バリデーションに失敗するとErrorをスローするようにしていました。ここでエラーを起こしてみて、どうなるか確認します。
src/domain/model/blog/factory.ts(抜粋)async create(denormalizedBlog: BlogResponse): Promise<Blog> { const model = plainToClass(Blog, denormalizedBlog); console.log(model); const errors = await validate(model); if (errors.length) { throw new Error(`モデルの作成に失敗しました。\n${errors}`); } return model; }意図的にモックのAPIレスポンスデータを不正な値にしてみます。
すると、こんな感じで、ErrorBoundaryでキャッチできていることがわかります。
ルーティング
ルーティングにはreact-router@v6を利用しました。
react-query、Suspenseの組み合わせで非同期処理をしていくのは今後増えていきそうですが、v5以前のreact-routerではSuspenseとの相性が悪いようで、react-query、Suspenseを使いたいならv6を入れておいた方が後々楽だと思います。参考v6ではv3のときにあったようなルーティングを一箇所にネストして定義するやり方(nested routes)もできますし、v5の時のような各コンポーネントに分散してルーティングを書くこともできます。今回のサンプルアプリではv5の時のような各コンポーネントに分散して書く形で実装しました。
src/App.tsximport React, { useEffect } from "react"; import { Route, Routes, useLocation, useNavigate } from "react-router"; import Blog from "./components/pages/Blog"; import "./App.css"; function App() { const { hash, pathname } = useLocation(); const navigate = useNavigate(); useEffect(() => { if (pathname === "/") { navigate("/blog"); } }, [hash, pathname, navigate]); return ( <Routes> <Route path="blog/*" element={<Blog />} /> </Routes> ); } export default App;src/components/pages/Blog.tsximport React, { Suspense } from "react"; import { Route, Routes } from "react-router"; import CircularProgress from "@material-ui/core/CircularProgress"; import BlogList from "../containers/BlogList"; import BlogDetail from "../containers/BlogDetail"; import ErrorBoundary from "../../ErrorBoundary"; function BlogPage() { return ( <ErrorBoundary> <Routes> <Route path="" element={ <Suspense fallback={<CircularProgress />}> <BlogList /> </Suspense> } ></Route> <Route path=":blogId" element={ <Suspense fallback={<CircularProgress />}> <BlogDetail /> </Suspense> } /> </Routes> </ErrorBoundary> ); } export default BlogPage;Redux toolkit
グローバルなstateの管理には@reduxjs/toolkitを利用しました。
普通にreduxを利用した場合はtypescriptとの相性やaction、reducerなどのボイラープレート的なコード量の多さに辟易していきますが、redux toolkitを利用した場合はそこらへんのめんどくささが結構解消されているため、使いやすく感じてます。(アクションとかは自分で書く必要はなくなるし、tsとの相性もいい)ディレクトリ構造はfeaturesディレクトリのなかにactionとreducerをまとめるようにしています。
├── src │ ├── features // reduxのaction、reducer │ │ └── blog │ │ ├── action.ts │ │ └── reducer.tsaction
src/features/blog/action.tsimport { NormalizedBlogs } from "../../domain/api/blog/get/schema"; import { RootState } from "../../store"; import { blogSlice } from "./reducer"; const { actions } = blogSlice; export const selectBlog = (state: RootState): NormalizedBlogs => state.blog; export const isEmpty = (state: RootState): boolean => !!Object.keys(state.blog.entities.blogs).length; export default actions;reducer
src/features/blog/reducer.tsimport { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { NormalizedBlogs } from "../../domain/api/blog/get/schema"; const initialState: NormalizedBlogs = { entities: { users: {}, comments: {}, blogs: {}, }, result: { blogs: [], }, }; export const blogSlice = createSlice({ name: "blog", initialState, reducers: { set: (state, action: PayloadAction<NormalizedBlogs>): NormalizedBlogs => { return { ...state, ...action.payload }; }, }, }); export default blogSlice.reducer;今回のアプリの設計では、reduxのstoreにはnormalizrで正規化したデータを格納します。
そのため、stateのinitialStateの型は正規化したデータの型を指定しています。src/features/blog/reducer.ts(抜粋)import { NormalizedBlogs } from "../../domain/api/blog/get/schema"; const initialState: NormalizedBlogs = { entities: { users: {}, comments: {}, blogs: {}, }, result: { blogs: [], }, };最後に
今回思いつきで作ってみましたが、実際にこのアーキテクチャで業務コードを書いたことはないため、スケールしていくコードにどれだけ耐えれる設計なのかは実際に試してみようと思いました。
また、normalizrは実際業務で使ったことはなく、初めて使ってみたのですがスキーマなどの型定義が結構めんどくさく、「ストアの正規化ってそこまで恩恵あるのかな??」と懐疑的な気持ちになりました。
- 投稿日:2020-09-27T12:13:10+09:00
[MERN⑩] Posts & Comments
~~~~~~~~~~ (Contents) MERN ~~~~~~~~~~~
[MERN①] Express & MongoDB Setup
https://qiita.com/niyomong/private/3281af84486876f897f7
[MERN②]User API Routes & JWT Authentication
https://qiita.com/niyomong/private/c11616ff7b64925f9a2b
[MERN③] Profile API Routes
https://qiita.com/niyomong/private/8cff4e6fa0e81b92cb49
[MERN④] Post API
https://qiita.com/niyomong/private/3ce66f15375ad04b8989
[MERN⑤] Getting Started With React & The Frontend
https://qiita.com/niyomong/private/a5759e2fb89c9f222b6b
[MERN⑥] Redux Setup & Alerts
https://qiita.com/niyomong/private/074c27259924c7fd306b
[MERN⑦] React User Authentication
https://qiita.com/niyomong/private/37151784671eff3b92b6
[MERN⑧] Dashboard & Profile Management
https://qiita.com/niyomong/private/ab7e5da1b1983a226aca
[MERN⑨] Profile Display
https://qiita.com/niyomong/private/42426135e959c7844dcb
[MERN⑩] Posts & Comments
https://qiita.com/niyomong/private/19c78aea482b734c3cf5
[MERN11] デプロイ
https://qiita.com/niyomong/private/150f9000ce51548134ad
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~1. Post Reducer, Action & Initial Component
① GET_POSTSとPOST_ERRORアクションを設置
//... export const ACCOUNT_DELETED = 'ACCOUNT_DELETED'; + export const GET_POSTS = 'GET_POSTS'; + export const POST_ERROR = 'POST_ERROR';② postReducerを生成
reducers/post.js//... import profile from './profile'; + import post from './post'; export default combineReducers({ alert, auth, profile, + post, });reducers/post.jsimport { GET_POSTS, POST_ERROR } from '../actions/types'; const initialState = { posts: [], post: null, loading: true, error: {}, }; export default function (state = initialState, action) { const { type, payload } = action; switch (type) { case GET_POSTS: return { ...state, posts: payload, loading: false, }; case POST_ERROR: return { ...state, error: payload, loading: false, }; default: return state; } }③ postアクション作成
actions/post.jsimport axios from 'axios'; import { setAlert } from './alert'; import { GET_POSTS, POST_ERROR } from './types'; // Get posts export const getPosts = () => async (dispatch) => { try { const res = await axios.get('/api/posts'); dispatch({ type: GET_POSTS, payload: res.data, }); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };④
posts
ルートをApp.jsに設置src/App.js//... + import Posts from './components/posts/Posts'; import PrivateRoute from './components/routing/PrivateRoute'; //... <Switch> <Route exact path="/register" component={Register} /> <Route exact path="/login" component={Login} /> + <Route exact path="/posts" component={Posts} /> <PrivateRoute exact path="/dashboard" component={Dashboard} /> //...⑤ 左上のサービス名にPosts一覧リンクを設置
components/layout/Navbar.js//... return ( <nav className="navbar bg-dark"> <h1> + <Link to="/posts"> <i className="fas fa-code" /> Refnote </Link> </h1> {!loading && ( <Fragment>{isAuthenticated ? authLinks : guestLinks}</Fragment> //...2. Post Item Component
① Postsコンポーネントを生成。
components/posts/Posts.jsimport React, { Fragment, useEffect } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import Spinner from '../layout/Spinner'; import PostItem from './PostItem'; import { getPosts } from '../../actions/post'; const Posts = ({ getPosts, post: { posts, loading } }) => { useEffect(() => { getPosts(); }, [getPosts]); return loading ? ( <Spinner /> ) : ( <Fragment> <h1 className="large text-primary">Posts</h1> <p className="lead"> <i className="fas fa-user" /> Welcome to the Posts </p> {/* PostForm */} <div className="Posts"> {posts.map((post) => ( <PostItem key={post._id} post={post} /> ))} </div> </Fragment> ); }; Posts.propTypes = { getPosts: PropTypes.func.isRequired, post: PropTypes.object.isRequired, }; const mapStateToProps = (state) => ({ post: state.post, }); export default connect(mapStateToProps, { getPosts })(Posts);② PostItemコンポーネントを生成。
以下項目追加。
・写真{avatar}、ユーザ名{name}、投稿日時{date}、投稿内容{text}、likes数{likes.length}、comments数{comments.length}、認証済の場合の削除ボタン{!auth.loading && user ===...}
・ProfileDisplayリンク設置{/profile/${user}
}components/posts/PostItem.jsimport React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import Moment from 'react-moment'; import { connect } from 'react-redux'; const PostItem = ({ auth, post: { _id, text, name, avatar, user, likes, comments, date }, }) => { return ( <div class="post bg-white p-1 my-1"> <div> <Link to={`/profile/${user}`}> <img class="round-img" src={avatar} alt="" /> <h4>{name}</h4> </Link> </div> <div> <p class="my-1">{text}</p> <p class="post-date"> Posted on <Moment format="YYY/MM/DD">{date}</Moment> </p> <button type="button" class="btn btn-light"> <i class="fas fa-thumbs-up" />{' '} {likes.length > 0 && <span>{likes.length}</span>} </button> <button type="button" class="btn btn-light"> <i class="fas fa-thumbs-down"></i> </button> <Link to={`/posts/${_id}`} class="btn btn-primary"> Discussion{' '} {comments.length > 0 && ( <span class="comment-count">{comments.length}</span> )} </Link> {auth.isAuthenticated === false ? null : !auth.loading && user === auth.user._id && ( <button onClick={(e) => deletePost(_id)} type="button" className="btn btn-danger" > <i className="fas fa-times"></i> </button> )} </div> </div> ); }; PostItem.propTypes = { post: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, }; const mapStateToProps = (state) => ({ auth: state.auth, }); export default connect(mapStateToProps, {})(PostItem);3. Like & Unlike Functionality
① TYPEアクションにUPDATE_LIKES設置
actions/types.js
//...
+ export const UPDATE_LIKES = 'UPDATE_LIKES';
② Likeアクションを設置
・Get postsをコピペしてAddLike
を加工
・api/postsAPIにputしたい(payloadは、postのidとlikes)
・removeLikeはaddLikeとほぼ同じ。actions/post.js//... + import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types'; //... // 以下、Get postsをコピペして加工 // Add like export const AddLike = (id) => async (dispatch) => { try { const res = await axios.put(`/api/posts/like/${id}`); dispatch({ type: UPDATE_LIKES, payload: { id, likes: res.data }, }); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } }; // Remove like export const removeLike = (id) => async (dispatch) => { try { const res = await axios.put(`/api/posts/unlike/${id}`); dispatch({ type: UPDATE_LIKES, payload: { id, likes: res.data }, }); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };③ UPDATE_LIKE Reducerを設置
・postsを操作する。each post
・postのidとpayloadのid(actionで設置)がマッチしていれば、postとlikesを返す。マッチしていなければ、do nothing(ただpostを返す)reducers/post.js+ import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from '../actions/types'; //...以下追加。 case UPDATE_LIKES: return { ...state, posts: state.posts.map((post) => post._id === payload.id ? { ...post, likes: payload.likes } : post ), loading: false, }; default: return state; } }④ PostItemコンポーネントにLikeボタン設置
components/posts/PostItem.js//... + import { addLike, removeLike } from '../../actions/post'; const PostItem = ({ + addLike, + removeLike, auth, post: { _id, text, name, avatar, user, likes, comments, date }, }) => { return ( //... <button + onClick={(e) => addLike(_id)} type="button" className="btn btn-light" > <i className="fas fa-thumbs-up" />{' '} {likes.length > 0 && <span>{likes.length}</span>} </button> <button + onClick={(e) => removeLike(_id)} type="button" className="btn btn-light" > <i className="fas fa-thumbs-down"></i> </button> //... PostItem.propTypes = { post: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, + addLike: PropTypes.func.isRequired, + removeLike: PropTypes.func.isRequired, }; + export default connect(mapStateToProps, { addLike, removeLike })(PostItem);4. Deleting Posts
① DELETE_POSTタイプ
actions/types.js//... + export const DELETE_POST = 'DELETE_POST';② deletePostアクション設置
・removeLikeアクションをコピペして更新
actions/post.js//... + import { DELETE_POST, GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types'; //... // Delete post export const deletePost = (id) => async (dispatch) => { try { await axios.delete(`/api/posts/${id}`); dispatch({ type: DELETE_POST, payload: id, }); dispatch(setAlert('Post Removed', 'success')); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };③ DELETE_POST reducer
reducers/post.js//.. UPDATE_LIKES, + DELETE_POST, } from '../actions/types'; //... + case DELETE_POST: + return { + ...state, + posts: state.posts.filter((post) => post._id !== payload), + loading: false, + }; //...④ Deleteボタン設置。
components/posts/PostItem.js//... + import { addLike, removeLike, deletePost } from '../../actions/post'; const PostItem = ({ addLike, removeLike, + deletePost, auth, post: { _id, text, name, avatar, user, likes, comments, date }, }) => { return ( //... {!auth.loading && user === auth.user._id && ( <button + onClick={(e) => deletePost(_id)} type="button" className="btn btn-danger" > <i className="fas fa-times"></i> </button> )} //... PostItem.propTypes = { post: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, addLike: PropTypes.func.isRequired, + removeLike: PropTypes.func.isRequired, + deletePost: PropTypes.func.isRequired, }; const mapStateToProps = (state) => ({ auth: state.auth, }); + export default connect(mapStateToProps, { addLike, removeLike, deletePost })( PostItem );5. Adding Posts
① ADD_POSTアクションタイプ設置
actions/types.js//... + export const ADD_POST = 'ADD_POST';② addPostアクション
・deletePostアクションをコピペして編集。
actions/post.js//... import { + ADD_POST, DELETE_POST, GET_POSTS, POST_ERROR, UPDATE_LIKES, } from './types'; //... // Add post export const addPost = (formData) => async (dispatch) => { const config = { headers: { 'Content-Type': 'application/json', }, }; try { const res = await axios.post('/api/posts', formData, config); dispatch({ type: ADD_POST, payload: res.data, }); dispatch(setAlert('Post Created', 'success')); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };④ ADD_POST Reducer
~~~ 説明 ~~~
posts: [payload(Newポスト), ...state.posts(既存ポスト配列)]
→ 配列の順番もNewポストが一番上に来るようになる。reducers/post.jsimport { GET_POSTS, POST_ERROR, UPDATE_LIKES, DELETE_POST, + ADD_POST, } from '../actions/types'; //... switch (type) { //... + case ADD_POST: + return { + ...state, + posts: [payload, ...state.posts], + loading: false, + }; //...⑤ポストフォームコンポーネントを生成
【前提】 rafcp + htmlテンプレ + Fragment+className以外の追加を + で表記。
components/posts/PostForm.js+ import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; + import { connect } from 'react-redux'; + import { addPost } from '../../actions/post'; + const PostForm = ({ addPost }) => { + const [text, setText] = useState(''); return ( <Fragment> <div className="post-form"> <div className="bg-primary p"> <h3>Say Something...</h3> </div> <form className="form my-1" + onSubmit={(e) => { + e.preventDefault(); + addPost({ text }); + setText(''); + }} > <textarea name="text" cols="30" rows="5" placeholder="Create a post" + value={text} + onChange={(e) => setText(e.target.value)} required ></textarea> <input type="submit" className="btn btn-dark my-1" value="Submit" /> </form> </div> </Fragment> ); }; PostForm.propTypes = { + addPost: PropTypes.func.isRequired, }; + export default connect(null, { addPost })(PostForm);⑥Postコンポーネントにを追加。
components/posts/Post.js//... import PostItem from './PostItem'; + import PostForm from './PostForm'; import { getPosts } from '../../actions/post'; //... return loading ? ( <Spinner /> ) : ( <Fragment> <h1 className="large text-primary">Posts</h1> <p className="lead"> <i className="fas fa-user" /> Welcome to the Posts </p> + <PostForm /> <div className="Posts"> {posts.map((post) => ( <PostItem key={post._id} post={post} /> ))} </div> </Fragment> ); }; //...6. Single Post Display
GET_POSTアクションタイプ
actions/types.js//... export const GET_POSTS = 'GET_POSTS'; + export const GET_POST = 'GET_POST'; //...① getPost
・getPostsをコピペして一部修正。
actions/post.js//... import { GET_POSTS, POST_ERROR, UPDATE_LIKES, DELETE_POST, ADD_POST, + GET_POST, } from './types'; //... //以下すべて追加。 // Get post export const getPost = (id) => async (dispatch) => { try { const res = await axios.get('/api/posts/${id}'); dispatch({ type: GET_POST, payload: res.data, }); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };② GET_POSTリデューサーを設置。
reducers/post.jsimport { GET_POSTS, POST_ERROR, UPDATE_LIKES, DELETE_POST, ADD_POST, + GET_POST, } from '../actions/types'; //... switch (type) { case GET_POSTS: return { ...state, posts: payload, loading: false, }; + case GET_POST: + return { + ...state, + post: payload, + loading: false, + };③ Postルート設置。
src/App.js//... import Posts from './components/posts/Posts'; + import Post from './components/post/Post'; //... <Route exact path="/posts" component={Posts} /> + <Route exact path="/posts/:id" component={Post} /> //...④ PostItemに
showActions
を設置~~~ 説明 ~~~
showActions: 他のコンポーネントでshowActions内のFragmentは表示させないようにする
・PostItemコンポーネント上では表示させたいので、true
設定
・Postコンポーネント上では非表示にしたいので、false
設定components/posts/PostItem.js//... const PostItem = ({ addLike, removeLike, deletePost, auth, post: { _id, text, name, avatar, user, likes, comments, date }, + showActions, }) => { return ( //... + {showActions && ( + <Fragment> <button onClick={(e) => addLike(_id)} type="button" className="btn btn-light" > //... {!auth.loading && user === auth.user._id && ( <button onClick={(e) => deletePost(_id)} type="button" className="btn btn-danger" > <i className="fas fa-times"></i> </button> )} + </Fragment> + )} </div> </div> </Fragment> ); }; + PostItem.defaultProps = { + showActions: true, + }; PostItem.propTypes = { post: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, addLike: PropTypes.func.isRequired, removeLike: PropTypes.func.isRequired, deletePost: PropTypes.func.isRequired, }; //...⑤ Postコンポーネント
PostItemコンポーネントを持ってくる(しかし、showActionsをfalseにする)
components/post/Post.jsimport React, { Fragment, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; import Spinner from '../layout/Spinner'; import PostItem from '../posts/PostItem'; import { getPost } from '../../actions/post'; const Post = ({ getPost, post: { post, loading }, match }) => { useEffect(() => { getPost(match.params.id); }, [getPost]); return loading || post === null ? ( <Spinner /> ) : ( <Fragment> <Link to="/posts" className="btn"> Back To Posts </Link> <PostItem post={post} showActions={false} /> </Fragment> ); }; Post.propTypes = { getPost: PropTypes.func.isRequired, post: PropTypes.object.isRequired, }; const mapStateToProps = (state) => ({ post: state.post, }); export default connect(mapStateToProps, { getPost })(Post);7. Adding Comments
① アクションタイプ設定
actions/types.js//... export const ADD_POST = 'ADD_POST'; + export const ADD_COMMENT = 'ADD_COMMENT'; + export const REMOVE_COMMENT = 'REMOVE_COMMENT';② addComment + deleteComment アクションを追加。
・addComment: addPostアクションをコピペして修正。
・deleteComment: addCommentアクションをコピペして修正。actions/post.jsimport { GET_POSTS, POST_ERROR, UPDATE_LIKES, DELETE_POST, ADD_POST, GET_POST, + ADD_COMMENT, + REMOVE_COMMENT, } from './types'; //... //以下全て追加。 // Add comment export const addComment = (postId, formData) => async (dispatch) => { const config = { headers: { 'Content-Type': 'application/json', }, }; try { const res = await axios.post( `/api/posts/comment/${postId}`, formData, config ); dispatch({ type: ADD_COMMENT, payload: res.data, }); dispatch(setAlert('Comment Added', 'success')); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } }; // Delete comment export const deleteComment = (postId, commentId) => async (dispatch) => { try { const res = await axios.delete(`/api/posts/comment/${postId}/${commentId}`); dispatch({ type: REMOVE_COMMENT, payload: commentId, }); dispatch(setAlert('Comment Removed', 'success')); } catch (err) { dispatch({ type: POST_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } };③ ADD_COMMENT + REMOVE_COMMENT リデューサー設置
・
comments: payload
-> postのすべてのコメントデータを持ってくる。
・comments: state.post.comments.filter((comment) => comment._id !== payload)
-> 削除したcomment._id以外のすべてのコメントデータを持ってくる。reducers/post.jsimport { GET_POSTS, POST_ERROR, UPDATE_LIKES, DELETE_POST, ADD_POST, GET_POST, + ADD_COMMENT, + REMOVE_COMMENT, } from '../actions/types'; //... case ADD_COMMENT: return { ...state, post: { ...state.post, comments: payload }, loading: false, }; case REMOVE_COMMENT: return { ...state, post: { ...state.post, comments: state.post.comments.filter( (comment) => comment._id !== payload ), }, loading: false, }; default: return state; } }④ コメントフォームを設置。
・Fragment内は、PostFormからコピペして修正。
components/post/CommentForm.jsimport React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { addComment } from '../../actions/post'; const CommentForm = ({ postId, addComment }) => { const [text, setText] = useState(''); return ( <Fragment> <div className="post-form"> <div className="bg-primary p"> <h3>Leave a Comment</h3> </div> <form className="form my-1" onSubmit={(e) => { e.preventDefault(); addComment(postId, { text }); setText(''); }} > <textarea name="text" cols="30" rows="5" placeholder="Create a post" value={text} onChange={(e) => setText(e.target.value)} required ></textarea> <input type="submit" className="btn btn-dark my-1" value="Submit" /> </form> </div> </Fragment> ); }; CommentForm.propTypes = { addComment: PropTypes.func.isRequired, }; export default connect(null, { addComment })(CommentForm);⑤ PostページにCommentFormを設置。
components/post/Post.js//... import PostItem from '../posts/PostItem'; + import CommentForm from '../post/CommentForm'; import { getPost } from '../../actions/post'; //... return loading || post === null ? ( <Spinner /> ) : ( <Fragment> <Link to="/posts" className="btn"> Back To Posts </Link> <PostItem post={post} showActions={false} /> + <CommentForm postId={post._id} /> </Fragment> ); }; //...8. Comment Display & Delete
use
IDはcomment
から持ってくる。components/post/CommentItem.jsimport React, { Fragment } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import Moment from 'react-moment'; import { deleteComment } from '../../actions/post'; const CommentItem = ({ postId, comment: { _id, text, name, avatar, user, date }, auth, deleteComment, }) => { return ( <Fragment> <div class="post bg-white p-1 my-1"> <div> <Link to={`/profile/${user}`}> <img class="round-img" src={avatar} alt="" /> <h4>{name}</h4> </Link> </div> <div> <p class="my-1">{text}</p> <p class="post-date"> Posted on <Moment format="YYY/MM/DD">{date}</Moment> </p> {!auth.loading && user === auth.user._id && ( <button onClick={(e) => deleteComment(postId, _id)} type="button" className="btn btn-danger" > <i className="fas fa-times" /> </button> )} </div> </div> </Fragment> ); }; CommentItem.propTypes = { postId: PropTypes.string.isRequired, comment: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, deleteComment: PropTypes.func.isRequired, }; const mapStateToProps = (state) => ({ auth: state.auth, }); export default connect(mapStateToProps, { deleteComment })(CommentItem);
- 投稿日:2020-09-27T12:02:22+09:00
Reactでビデオ要素をキャプチャしてDBに保存する
Reactでビデオ要素をキャプチャしてDBに保存する実装のメモ。
サーバサイドは/api/internal/register_imageのエンドポイントを作ってbinary型のカラムに保存できるようにしておく
フロント側の実装
video要素をcanvasに描画してそのままrequestで送れる。
const sample = () => { const videoCapture = () => { const canvas = document.getElementById('video-canvas'); const videoHeight = video.videoHeight; const videoWidth = video.videoWidth; canvas.height = videoHeight; canvas.width = videoWidth; canvas.getContext('2d').drawImage(video, 0, 0); const canvas_image = canvas.toDataURL('image/png'); axios.post( '/api/internal/register_image',{ headers: { 'content-type': 'multipart/form-data', }, image: image, } ); } return ( <video></video> <canvas id='video-canvas' hidden><canvus> <button onClick={videoCapture}>キャプチャする</button> ) }こんな形式の値がpostされる。
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAgAElEQVR4Xuy9iZOt2VXdue98c8431CghkISMaSOMsY3H6Gg3NgaDcdvR0dER/RfbbcAYDBJSSfVevfll5svxzkPHb6297/0y65WqJAGtwmRFxsvKvPe733DOPuusvfbarfVqvY4vy1frzom+7cz5XTsi+JfXL6/i5vpVrJfnsV5No9XqRKvVisVyEa31Mq7O38T5yat49eyTePH0SQx7/Viv1/o7t6a1jlivV7FuraPX68VyuY7Vahk345sYjUbx7W//evzSL/+92Dk4jGjxgatoxSrWsdTP88U8FouF3sNXuxXR7XRivV7qPFarVbQ7bb231WpHSycdsVwto91uR7fbjU6np4vi7+v1IpbLeazWK72OY7U7Hf3Mefa6Pf3/StdfN4x/W9FucWP8tVrx+rWucbla6WfOh+9O18fk53Z+87Pf6JvO6/Uv3/xtnX9vrfWeTqcfwed1u9Fqc34+B73B7/Lx9E/+nmPynnY7gnuiB9mN1bqle1H/vx0F9T7+lsfP+6fjbL5aES3OIY+xrmO1dWzOvd1uNc9K1/5X/eVPqHuSn8aAa37Vff2rPpmf9vh12s0hd+s6bj+OT39cjQn+Ze7No617sY5VLGOxmsR8NY3ZfByL1SouLm/i//2vfxh/9Ad/HOPRNF6/fh3X1zcxmy2i3xtqXjFX+4Od2N8/1Lwfj2exWC41hJlL3U4vOp1uaNi1WrFcLmI+X8R0Oo35jHm7jm6rp/fyNZlMNHF2d3b1+n6fWBGxXK70nn5/EMvFMqbTmeZ9p9PRXGIuL+dzz3WN44jVgniw1rjr9brRbncUI3gNX/y+xXfOSeZoxZLlchmz2Uyftbe/p/csFkvNOz6Lr8FgoDlMHOt2iR+cJ8ePmM6mGnbElna7q3lR94HX9bpdvZ/jdns9xRXu12q50n2ZjCdxfn6+iRnEnOFgGMP+IHaGg+j2OjFfTmK1WsRitojDw8P4lV/9+/Huu+/G48eP4+TkJK6urmI0HuserloR07mfjeJUuxPtbkd/6/X7Opf9g8PY29+Pfq+va1EsXiz0LPg7r+VrOh7HarXWuXLPBsMd/b2nmLuKyWQco9GNrmExn8V0NtbvHPPnEe0lISu67XX0OxE7g07s9tv
- 表示側 サーバ側から上記の形式でデータを受け取ってそのままimgタグのsrcに指定して表示できる
const [images, setImages] = useState([]); useEffect(() => { async function fetchImages() { const images = await axios.get '/api/internal/images' ); setImages(images.data); } fetchImages(); }, []); return ( images.map((image, i) => ( <img src={image.image} key={i}</img> ) )
- 投稿日:2020-09-27T09:23:48+09:00
React NativeでのModalの作り方
はじめに
現在、React Nativeでアプリを開発しており、いい感じのModal(ボタンを押したら下から画面が出てくるやつ)を作ろうとしたのですが、悪戦苦闘したため、記事に残し備忘録の1つとして残します。
作り方
まずはModal画面に遷移するボタンを作成しましょう。ちなみに、今回はuseStateを使用します。
App.jsexport default function App() { const [modalOpen, setModalOpen] = useState(false); return ( <View> <View> <Text style={{ fontSize: 24 }}>Lists</Text> <TouchableHighlight style={styles.openButton} onPress={() => { setModalOpen(true); }} > <Text>Add</Text> </TouchableHighlight> </View> ); } // スタイル const styles = StyleSheet.create({ openButton: { backgroundColor: "#F194FF", borderRadius: 20, padding: 10, paddingLeft: 20, paddingRight: 20, elevation: 2, }, });Buttonタグでもいいのですが、ボタンらしいスタイルにしたいので今回はTouchableHighlightを使用しました。以下のスタイルのようになります。
Modalを追加
App.jsreturn ( <View> {/* 投稿画面 */} <Modal visible={modalOpen} animationType="slide"> <View style={StyleSheet.modelContent}> <Text style={{ fontSize: 30, paddingTop: 50, marginLeft: "auto", marginRight: "auto", marginTop: "auto", marginBottom: 70, }} > Modal画面 </Text> </View> </Modal> <View> ~ </View> </View> );先ほどのコードの上にModalを追加すれば、完成です。Modal画面とボタンがある画面は別のファイルにコード記載した方が良さそうですね。今回は説明のためにあえて一緒に記述させました。以下がModal画面です。
これが下から出てきます。あとがき
本当はMaterial Iconを押した時に、Modalが出てくるようにしたいのですが、調べてもよくわからなかったので、自作でボタンを作成して実装しました。分かる方がいましたら、教えて欲しいです。
参考
- 投稿日:2020-09-27T02:44:46+09:00
GraphQL入門
はじめに
対象読者
- GraphQLについてざっくり学びたい人
- 公式ドキュメント読むのがしんどい人
- Apollo/Relayどっちを使うか迷っている人
GraphQL概論編
RESTful API
代表的なWeb APIである、REST APIについて、簡単にまとめつつ、
GraphQLでできることについて、書いていこうと思います。RESTful API(REST API)とは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたものです。RESTそのものは適用範囲の広い抽象的なモデルだが、一般的にはRESTの考え方をWeb APIに適用したものをRESTful APIと呼んでいます。
RESTful APIでは、URL/URIですべてのリソースを一意に識別し、クライアントがHTTP(あるいはHTTPS)で要求(リクエスト)を送信すると、応答(レスポンス)がXMLやHTML、JSON、CSV、プレーンテキストなどで返されます。
引用元とIT用語辞典に書いてあるのですが、かなりざっくりまとめると、
REST APIでは、あるURIにアクセスすると、そのURIに結びつく情報をレスポンスとして返します。
例えば、以下のエンドポイントにリクエストを送信すると、それぞれ固有のレスポンスを返すということです。
例:
/api/food/hot-dog
/api/sport/skiing
/api/city/TokyoRESTful APIの特徴
RESTful APIの特徴の一つとして、過剰な取得というものがあります。
説明のためにREST API版のSWAPIを利用し、https://swapi.dev/api/people/1/にGETリクエストを投げてみます。
下記にResponseを記載します。
ですが、もしクライアントが本当に必要だった情報が"name", "height", "mass"だった場合、余分なデータが多いことが分かります。
これが、REST APIの過剰取得という特徴です。ただ、気をつけたいのは、この特徴はデメリットということではなく、あくまでのその使用用途に依存します。なので、通常REST APIを使用する場合は、不必要な情報は使い捨てるような使い方になると思います。{ "name": "Luke Skywalker", "height": "172", "mass": "77", "hair_color": "blond", "skin_color": "fair", "eye_color": "blue", "birth_year": "19BBY", "gender": "male", "homeworld": "http://swapi.dev/api/planets/1/", "films": [ "http://swapi.dev/api/films/1/", "http://swapi.dev/api/films/2/", "http://swapi.dev/api/films/3/", "http://swapi.dev/api/films/6/" ], "species": [], "vehicles": [ "http://swapi.dev/api/vehicles/14/", "http://swapi.dev/api/vehicles/30/" ], "starships": [ "http://swapi.dev/api/starships/12/", "http://swapi.dev/api/starships/22/" ], "created": "2014-12-09T13:50:51.644000Z", "edited": "2014-12-20T21:17:56.891000Z", "url": "http://swapi.dev/api/people/1/" }GraphQLって何???
まず、GraphQLを学ぶにあたり、グラフ理論についてざっくり知っているといいので、説明します。
グラフ理論
グラフ理論とは、ノード(頂点)の集合とエッジ(辺)の集合で構成されるグラフに関する学問です。
例
この学問の発祥になったとされるのが、ケーニヒスベルクの橋の問題です。こちらは有名なので、別途調べていただけると面白いと思います。※ここでは、割愛させていただきます。
グラフ理論とGraphQL
グラフ理論とGraphQLの繋がりを分かりやすくする為に、FacebookのようなSNSを考えてみます。
例
下記の図は、アイアンマンを基点に彼のfacebookで繋がっている友人関係をリクエストした時の繋がりを表しています。
そうするとこのリクエストは、下記の木のような構造になります。
指定したアイアンマンが根で、そのアイアンマンに紐づく友人関係が子になる形です。
このリクエストで、アイアンマンは彼の友人同士とエッジで接続されていることが分かります。
実は、この構造がGraphQLのクエリに似ているのです。ツリー構造 ・person - name - location - birthday - friends * friend name * friend location * friend birthdayGraphQLのクエリ { me { name location birthday friends { name location birthday } } }GraphQLとは
- GraphQLとは、 API用のクエリ言語であり、TypeSystemを使用してクエリを実行するためのサーバ側のランタイムです。
- また、クライアントがサーバからデータを取得、変更、講読ができるようにするためのデータ言語でもあります。
GraphQLの特徴
- 型指定されたスキーマ
・ フロントエンジニアだけで、開発や対応ができるようになります。 ・ 型指定ができるので、型が堅牢になります。- クライアントからのレスポンス形式の指定
・ クライアントからレスポンスの形式を指定できるようになります。 これによりオーバフェッチ、アンダーフェッチがなくなります。- サブスクリプションを利用したリアルタイム処理
・ クライアントはデータをサブスクライブすることで、イベントドリブンに処理を実装することが可能になります。クライアントからのレスポンス形式の指定
クリーンなインターフェースを実現できます。
どういうことかというと、例えば、下図のようにREST APIを利用し、ある1つの画面に表示すべきデータをフェッチするのに、複数のリソースに対しAPIを叩かなければならない場合、URIを複数アクセスする必要があります。
それに比べ、GraphQLの場合ですと、1つのリクエストで複数のリソースに対し問い合わせをする処理をGraphQL側がまとめ処理することができます。
これによりクリーンなインターフェースになるということです。サブスクリプションを利用したリアルタイム処理 ①
Mutationをトリガーにしたイベントベースでの処理が行えます。
Mutationとは、GraphQLにおけるトランザクション処理(Put/Post/Delete)のことを指しています。
なので、クライアント側でPut/Post/DeleteをGraphQLサーバ側に行った場合、このPut/Post/Deleteの処理をサブスクリプションしているクライアントがいた場合、そのクライアントに対し、行われた処理内容のデータをpushすることができます。
サブスクリプションを利用したリアルタイム処理 ②
実際のGraphQLサーバ側のスキーマとClient側の処理例
サブスクリプションを利用したユースケース
もし、サブスクリプションを使うとした場合、考えられるユースケース
- チャットアプリ
- SNS
- お知らせ等の通知機能
etc...GraphQLに対する問題(デメリット)
- 変更管理
- ・ データを取得して操作するためにGlaphQL APIの仕様を理解する必要があります。
- ・ フロントエンドからのクエリを処理する方法を理解する必要があります。
- N + 1 問題
- 「1 つの SQL で N 件のレコードをフェッチしたあと、 それぞれ(フェッチした項目)に対して関連するレコードを個別にフェッチするのに N回 SQL を発行している」 状態です。
- これ何が問題かというと、この状態になると、RDB サーバと通信するための時間及びRDBMSがSQLを 解析する時間が増加する恐れが考えられます。
# N個のarticlesをフェッチする(SQLは1つ) SELECT * FROM articles; # articles ごとにuserをフェッチする(SQL1つがN回) SELECT * FROM users WHERE id = 1; SELECT * FROM users WHERE id = 2; SELECT * FROM users WHERE id = 3; SELECT * FROM users WHERE id = 4; SELECT * FROM users WHERE id = 5; SELECT * FROM users WHERE id = 6; ・ ・ ・
- Client側のキャッシュ戦略
- GraphQLは単一エンドポイントなので、HTTPのCache-ControllのようなURLベースのキャッシュ機構やCDNでのキャッシュ(reduxなど)はそのままでは使用ができません。
N + 1 問題
- 解決策
- N 回繰り返している SQL を 1 つにまとめる必要があります。
- → 遅延読み込み(Lazy Loading): 必要なデータが分かったあとで読み込み実施します。
- 遅延評価
- 必要なデータを一通り宣言したあとで、それらをまとめて一括で解決する。
- Resolver(スキーマによる名前解決)が promise を返した場合、その promise が fullfilled になるまで GraphQL は後続の評価を待つようにする。
Client側のキャッシュ戦略
- ネットワークキャッシング
- リクエストを傍受し、アプリケーションサーバーにアクセスする代わりに、メモリから直接応答を返します。
- → CDNでのキャッシュ(reduxなど)
- 解決策
- Client側における実装で、キャッシュを管理できるライブラリを使用します。
- 詳しくはGraphQL実装編で説明します。
GraphQL実装編
今回取り扱う実装パターン
- サーバサイド
- Express + Apollo server
- フロントエンド
- 1. React + Apollo client
- 2. React + Relay
Express, Apolloとは
- Express
- Node.js Web アプリケーションのフレームワークです。
- Apollo Pratform
- GraphQLのためのデータグラフの構築、クエリ、および管理を支援するライブラリです。
- データグラフとは、アプリケーションクライアントとバックエンドサービスの間に位置し、 それらの間のデータの流れを指します。
導入方法
- 前提条件
- Node.js v8.x 以上
- npm v6.x 以上
- git v2.14.1 以上
- Apollo Serverの複数の使用方法
- 1. サーバーレス環境を含むスタンドアロンのGraphQLサーバーです。
- 2. 既存のNode.jsミドルウェア(Expressなど)へのアドオンです。
- → Apolloプラットフォームは、段階的な採用を推奨している為です。
- 3. 複数のサービスにおける単一のデータグラフを利用するためのゲートウェイとしての使用です。
Apollo Serverによる実装例
- 依存関係のあるライブラリをinstall
- [apollo-server] : Apollo Server自体のコアライブラリです。
- [graphql] : GraphQLスキーマを構築し、それに対してクエリを実行するために使用されるライブラリです。
- GraphQLスキーマを定義
- クライアントがクエリできるデータの構造を定義します。
- クライアントは、 [books]というクエリを実行でき、サーバーは0個以上[Book]の配列を返します。
const { ApolloServer, gql } = require('apollo-server'); const typeDefs = gql` type Book { title: String author: String } type Query { book: [Book] } `;
- データセットの定義
- 上でデータ構造を定義したので、ここではデータ自体を定義します。
- 接続する任意のソース(データベース、REST API、または別のGraphQLサーバー等)からデータをフェッチできます。
- ここでは、サンプルデータをハードコードします。
const books = [ { title: 'xxxxxxxxxxxxxxx', author: 'yyyyyyyyyyyyyy', }, { title: 'xxxxxxxxxxx2', author: 'yyyyyyyyyy2', }, ];
- Resolverの定義
- Apolloサーバーはクエリの実行時にそのデータセットを使用する必要があることを認識していない。そのために、Resolverを作成します。
- Resolverは、特定の[type]に関連付けられたデータをフェッチする方法をApolloサーバーに伝えます。
const resolvers = { Query: { books: () => books, }, };
- Apollo-Severのインスタンスを作成
- 初期化時に、[スキーマ], [データセット], [Resolver]をApolloサーバーに提供します。
const server = new ApolloServer({ typeDefs, resolvers }); server.listen().then(({ url }) => { console.log(`? Server ready at ${url}`); });GraphQL Playgroundツールを利用してクエリを実行
筆者の都合により、レスポンス部分を隠しているのはご容赦ください。(個人情報のため。。)
Client側の実装例
- React + Apollo client
- React + Relay
Apollo Client / Relay
- Apollo clientは、キャッシュ機能と状態管理機能を備えたJavaScript GraphQLクライアントです。
- Relayは、GraphQLを利用したReactアプリケーションを構築するためのJavaScriptフレームワークです。
Apollo Client VS Relay
Apollo Clientにおけるキャッシュと状態管理
Relayにおけるキャッシュと状態管理
まとめ
- 実はApollo以外の選択肢として、AWS Appsyncを利用する方法もある。こちらについては、別記事としていずれ書こうと思います。
- Apolloを利用する場合のメリットは、Expressを利用したWeb APIを既に作成しており、プロジェクトの都合等でGraphQLを利用する場合、Apolloをアドオンすることができ、そうした場合、徐々にGraphQLへ移行することができる。
参考
- 投稿日:2020-09-27T02:44:46+09:00
GraphQL入門-初心者による初心者のためのGraphQL-
はじめに
対象読者
- GraphQLについてざっくり学びたい人
- 公式ドキュメント読むのがしんどい人
- Apollo/Relayどっちを使うか迷っている人
GraphQL概論編
RESTful API
代表的なWeb APIである、REST APIについて、簡単にまとめつつ、
GraphQLでできることについて、書いていこうと思います。RESTful API(REST API)とは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたものです。RESTそのものは適用範囲の広い抽象的なモデルだが、一般的にはRESTの考え方をWeb APIに適用したものをRESTful APIと呼んでいます。
RESTful APIでは、URL/URIですべてのリソースを一意に識別し、クライアントがHTTP(あるいはHTTPS)で要求(リクエスト)を送信すると、応答(レスポンス)がXMLやHTML、JSON、CSV、プレーンテキストなどで返されます。
[引用元]:http://e-words.jp/w/RESTful_API.htmlとIT用語辞典に書いてあるのですが、かなりざっくりまとめると、
REST APIでは、あるURIにアクセスすると、そのURIに結びつく情報をレスポンスとして返します。
例えば、以下のエンドポイントにリクエストを送信すると、それぞれ固有のレスポンスを返すということです。
例:
/api/food/hot-dog
/api/sport/skiing
/api/city/TokyoRESTful APIの特徴
RESTful APIの特徴の一つとして、過剰な取得というものがあります。
説明のためにREST API版のSWAPIを利用し、https://swapi.dev/api/people/1/にGETリクエストを投げてみます。
下記にResponseを記載します。
ですが、もしクライアントが本当に必要だった情報が"name", "height", "mass"だった場合、余分なデータが多いことが分かります。
これが、REST APIの過剰取得という特徴です。ただ、気をつけたいのは、この特徴はデメリットということではなく、あくまでのその使用用途に依存します。なので、通常REST APIを使用する場合は、不必要な情報は使い捨てるような使い方になると思います。{ "name": "Luke Skywalker", "height": "172", "mass": "77", "hair_color": "blond", "skin_color": "fair", "eye_color": "blue", "birth_year": "19BBY", "gender": "male", "homeworld": "http://swapi.dev/api/planets/1/", "films": [ "http://swapi.dev/api/films/1/", "http://swapi.dev/api/films/2/", "http://swapi.dev/api/films/3/", "http://swapi.dev/api/films/6/" ], "species": [], "vehicles": [ "http://swapi.dev/api/vehicles/14/", "http://swapi.dev/api/vehicles/30/" ], "starships": [ "http://swapi.dev/api/starships/12/", "http://swapi.dev/api/starships/22/" ], "created": "2014-12-09T13:50:51.644000Z", "edited": "2014-12-20T21:17:56.891000Z", "url": "http://swapi.dev/api/people/1/" }GraphQLって何???
まず、GraphQLを学ぶにあたり、グラフ理論についてざっくり知っているといいので、説明します。
グラフ理論
グラフ理論とは、ノード(頂点)の集合とエッジ(辺)の集合で構成されるグラフに関する学問です。
例
この学問の発祥になったとされるのが、ケーニヒスベルクの橋の問題です。こちらは有名なので、別途調べていただけると面白いと思います。※ここでは、割愛させていただきます。
グラフ理論とGraphQL
グラフ理論とGraphQLの繋がりを分かりやすくする為に、FacebookのようなSNSを考えてみます。
例
下記の図は、アイアンマンを基点に彼のfacebookで繋がっている友人関係をリクエストした時の繋がりを表しています。
そうするとこのリクエストは、下記の木のような構造になります。
指定したアイアンマンが根で、そのアイアンマンに紐づく友人関係が子になる形です。
このリクエストで、アイアンマンは彼の友人同士とエッジで接続されていることが分かります。
実は、この構造がGraphQLのクエリに似ているのです。ツリー構造 ・person - name - location - birthday - friends * friend name * friend location * friend birthdayGraphQLのクエリ { me { name location birthday friends { name location birthday } } }GraphQLとは
- GraphQLとは、 API用のクエリ言語であり、TypeSystemを使用してクエリを実行するためのサーバ側のランタイムです。
- また、クライアントがサーバからデータを取得、変更、講読ができるようにするためのデータ言語でもあります。
GraphQLの特徴
- 型指定されたスキーマ
・ フロントエンジニアだけで、開発や対応ができるようになります。 ・ 型指定ができるので、型が堅牢になります。- クライアントからのレスポンス形式の指定
・ クライアントからレスポンスの形式を指定できるようになります。 これによりオーバフェッチ、アンダーフェッチがなくなります。- サブスクリプションを利用したリアルタイム処理
・ クライアントはデータをサブスクライブすることで、イベントドリブンに処理を実装することが可能になります。クライアントからのレスポンス形式の指定
クリーンなインターフェースを実現できます。
どういうことかというと、例えば、下図のようにREST APIを利用し、ある1つの画面に表示すべきデータをフェッチするのに、複数のリソースに対しAPIを叩かなければならない場合、URIを複数アクセスする必要があります。
それに比べ、GraphQLの場合ですと、1つのリクエストで複数のリソースに対し問い合わせをする処理をGraphQL側がまとめ処理することができます。
これによりクリーンなインターフェースになるということです。サブスクリプションを利用したリアルタイム処理 ①
Mutationをトリガーにしたイベントベースでの処理が行えます。
Mutationとは、GraphQLにおけるトランザクション処理(Put/Post/Delete)のことを指しています。
なので、クライアント側でPut/Post/DeleteをGraphQLサーバ側に行った場合、このPut/Post/Deleteの処理をサブスクリプションしているクライアントがいた場合、そのクライアントに対し、行われた処理内容のデータをpushすることができます。
サブスクリプションを利用したリアルタイム処理 ②
実際のGraphQLサーバ側のスキーマとClient側の処理例
サブスクリプションを利用したユースケース
もし、サブスクリプションを使うとした場合、考えられるユースケース
- チャットアプリ
- SNS
- お知らせ等の通知機能
etc...GraphQLに対する問題(デメリット)
- 変更管理
- ・ データを取得して操作するためにGlaphQL APIの仕様を理解する必要があります。
- ・ フロントエンドからのクエリを処理する方法を理解する必要があります。
- N + 1 問題
- 「1 つの SQL で N 件のレコードをフェッチしたあと、 それぞれ(フェッチした項目)に対して関連するレコードを個別にフェッチするのに N回 SQL を発行している」 状態です。
- これ何が問題かというと、この状態になると、RDB サーバと通信するための時間及びRDBMSがSQLを 解析する時間が増加する恐れが考えられます。
# N個のarticlesをフェッチする(SQLは1つ) SELECT * FROM articles; # articles ごとにuserをフェッチする(SQL1つがN回) SELECT * FROM users WHERE id = 1; SELECT * FROM users WHERE id = 2; SELECT * FROM users WHERE id = 3; SELECT * FROM users WHERE id = 4; SELECT * FROM users WHERE id = 5; SELECT * FROM users WHERE id = 6; ・ ・ ・
- Client側のキャッシュ戦略
- GraphQLは単一エンドポイントなので、HTTPのCache-ControllのようなURLベースのキャッシュ機構やCDNでのキャッシュ(reduxなど)はそのままでは使用ができません。
N + 1 問題
- 解決策
- N 回繰り返している SQL を 1 つにまとめる必要があります。
- → 遅延読み込み(Lazy Loading): 必要なデータが分かったあとで読み込み実施します。
- 遅延評価
- 必要なデータを一通り宣言したあとで、それらをまとめて一括で解決する。
- Resolver(スキーマによる名前解決)が promise を返した場合、その promise が fullfilled になるまで GraphQL は後続の評価を待つようにする。
Client側のキャッシュ戦略
- ネットワークキャッシング
- リクエストを傍受し、アプリケーションサーバーにアクセスする代わりに、メモリから直接応答を返します。
- → CDNでのキャッシュ(reduxなど)
- 解決策
- Client側における実装で、キャッシュを管理できるライブラリを使用します。
- 詳しくはGraphQL実装編で説明します。
GraphQL実装編
今回取り扱う実装パターン
- サーバサイド
- Express + Apollo server
- フロントエンド
- 1. React + Apollo client
- 2. React + Relay
Express, Apolloとは
- Express
- Node.js Web アプリケーションのフレームワークです。
- Apollo Pratform
- GraphQLのためのデータグラフの構築、クエリ、および管理を支援するライブラリです。
- データグラフとは、アプリケーションクライアントとバックエンドサービスの間に位置し、 それらの間のデータの流れを指します。
導入方法
- 前提条件
- Node.js v8.x 以上
- npm v6.x 以上
- git v2.14.1 以上
- Apollo Serverの複数の使用方法
- 1. サーバーレス環境を含むスタンドアロンのGraphQLサーバーです。
- 2. 既存のNode.jsミドルウェア(Expressなど)へのアドオンです。
- → Apolloプラットフォームは、段階的な採用を推奨している為です。
- 3. 複数のサービスにおける単一のデータグラフを利用するためのゲートウェイとしての使用です。
Apollo Serverによる実装例
- 依存関係のあるライブラリをinstall
- [apollo-server] : Apollo Server自体のコアライブラリです。
- [graphql] : GraphQLスキーマを構築し、それに対してクエリを実行するために使用されるライブラリです。
- GraphQLスキーマを定義
- クライアントがクエリできるデータの構造を定義します。
- クライアントは、 [books]というクエリを実行でき、サーバーは0個以上[Book]の配列を返します。
const { ApolloServer, gql } = require('apollo-server'); const typeDefs = gql` type Book { title: String author: String } type Query { book: [Book] } `;
- データセットの定義
- 上でデータ構造を定義したので、ここではデータ自体を定義します。
- 接続する任意のソース(データベース、REST API、または別のGraphQLサーバー等)からデータをフェッチできます。
- ここでは、サンプルデータをハードコードします。
const books = [ { title: 'xxxxxxxxxxxxxxx', author: 'yyyyyyyyyyyyyy', }, { title: 'xxxxxxxxxxx2', author: 'yyyyyyyyyy2', }, ];
- Resolverの定義
- Apolloサーバーはクエリの実行時にそのデータセットを使用する必要があることを認識していない。そのために、Resolverを作成します。
- Resolverは、特定の[type]に関連付けられたデータをフェッチする方法をApolloサーバーに伝えます。
const resolvers = { Query: { books: () => books, }, };
- Apollo-Severのインスタンスを作成
- 初期化時に、[スキーマ], [データセット], [Resolver]をApolloサーバーに提供します。
const server = new ApolloServer({ typeDefs, resolvers }); server.listen().then(({ url }) => { console.log(`? Server ready at ${url}`); });GraphQL Playgroundツールを利用してクエリを実行
筆者の都合により、レスポンス部分を隠しているのはご容赦ください。(個人情報のため。。)
Client側の実装例
- React + Apollo client
- React + Relay
Apollo Client / Relay
- Apollo clientは、キャッシュ機能と状態管理機能を備えたJavaScript GraphQLクライアントです。
- Relayは、GraphQLを利用したReactアプリケーションを構築するためのJavaScriptフレームワークです。
Apollo Client VS Relay
Apollo Clientにおけるキャッシュと状態管理
Relayにおけるキャッシュと状態管理
まとめ
- 実はApollo以外の選択肢として、AWS Appsyncを利用する方法もある。こちらについては、別記事としていずれ書こうと思います。
- Apolloを利用する場合のメリットは、Expressを利用したWeb APIを既に作成しており、プロジェクトの都合等でGraphQLを利用する場合、Apolloをアドオンすることができ、そうした場合、徐々にGraphQLへ移行することができる。
参考
- [Running an Express GraphQL Server]:https://graphql.org/graphql-js/running-an-express-graphql-server/
- [Apollo]:https://www.apollographql.com/docs/
- [Apollo blog]:https://www.apollographql.com/blog/
- [Apollo community]:https://spectrum.chat/apollo?tab=posts
- [GraphQL API の設計 Tips]:https://www.slideshare.net/AmazonWebServices/ten-tips-and-tricks-for-improving-your-graphql-api-with-aws-appsync-mob401-aws-reinvent-2018
- 投稿日:2020-09-27T00:20:26+09:00
[MERN⑨] Profile Display
~~~~~~~~~~ (Contents) MERN ~~~~~~~~~~~
[MERN①] Express & MongoDB Setup
https://qiita.com/niyomong/private/3281af84486876f897f7
[MERN②]User API Routes & JWT Authentication
https://qiita.com/niyomong/private/c11616ff7b64925f9a2b
[MERN③] Profile API Routes
https://qiita.com/niyomong/private/8cff4e6fa0e81b92cb49
[MERN④] Post API
https://qiita.com/niyomong/private/3ce66f15375ad04b8989
[MERN⑤] Getting Started With React & The Frontend
https://qiita.com/niyomong/private/a5759e2fb89c9f222b6b
[MERN⑥] Redux Setup & Alerts
https://qiita.com/niyomong/private/074c27259924c7fd306b
[MERN⑦] React User Authentication
https://qiita.com/niyomong/private/37151784671eff3b92b6
[MERN⑧] Dashboard & Profile Management
https://qiita.com/niyomong/private/ab7e5da1b1983a226aca
[MERN⑨] Profile Display
https://qiita.com/niyomong/private/42426135e959c7844dcb
[MERN⑩] Posts & Comments
https://qiita.com/niyomong/private/19c78aea482b734c3cf5
[MERN11] デプロイ
https://qiita.com/niyomong/private/150f9000ce51548134ad
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~1. Finish Profile Actions & Reducer
アクションタイプ
actions/types.js//... export const GET_PROFILE = 'GET_PROFILE'; + export const GET_PROFILES = 'GET_PROFILES';Profileアクション
actions/profile.jsimport { CLEAR_PROFILE, GET_PROFILE, PROFILE_ERROR, ACCOUNT_DELETED, + GET_PROFILES, } from './types'; //... //以下追加。 // Get all profiles export const getProfiles = () => async (dispatch) => { dispatch({ type: CLEAR_PROFILE }); try { const res = await axios.get('/api/profile'); dispatch({ type: GET_PROFILES, payload: res.data, }); } catch (err) { dispatch({ type: PROFILE_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } }; //...profileリデューサー
reducers/profile.js//... case GET_PROFILES: return { ...state, profiles: payload, loading: false, }; //...getProfileByIdアクション
actions/profile.js// Get profile by ID export const getProfileById = (userId) => async (dispatch) => { dispatch({ type: CLEAR_PROFILE }); try { const res = await axios.get(`/api/profile/user/${userId}`); dispatch({ type: GET_PROFILE, payload: res.data, }); } catch (err) { dispatch({ type: PROFILE_ERROR, payload: { msg: err.response.statusText, status: err.response.status }, }); } }; //...2. Display Profiles
① components/profilesフォルダ作成
・Profilesコンポーネント生成 (Profilesページ)
・ProfileItemコンポーネント生成 (Profilesの一部)② ProfilesページをルートとNavbarにを設置。
src/App.js//... import EditProfile from './components/profile-forms/EditProfile'; + import Profiles from './components/profiles/Profiles'; //... <Switch> <Route exact path="/register" component={Register} /> <Route exact path="/login" component={Login} /> + <Route exact path="/profiles" component={Profiles} /> //...components/layout/Navbar.js//... const Navbar = ({ auth: { isAuthenticated, loading }, logout }) => { const authLinks = ( <ul> + <li> + <Link to="/profiles">Users</Link> + </li> <li> <Link to="/dashboard"> <i className="fas fa-user" /> <span className="hide-sm">Dashboard</span> </Link> //... const guestLinks = ( <ul> + <li> + <Link to="/profiles">Users</Link> + </li> <li> <Link to="/register">Register</Link> </li> //...③ Profilesコンポーネント(Profilesページ)
components/profiles/Profiles.jsimport React, { Fragment, useEffect } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import Spinner from '../layout/Spinner'; import ProfileItem from './ProfileItem'; import { getProfiles } from '../../actions/profile'; const Profiles = ({ getProfiles, profile: { profiles, loading } }) => { useEffect(() => { getProfiles(); }, [getProfiles]); return ( <Fragment> {loading ? ( <Spinner /> ) : ( <Fragment> <h1 className="large text-primary">Developers</h1> <p className="lead"> <i className="fab fa-connectdevelop" /> Browse and connect with developers </p> <div className="profiles"> {profiles.length > 0 ? ( profiles.map((profile) => ( <ProfileItem key={profile._id} profile={profile} /> )) ) : ( <h4>No profiles found...</h4> )} </div> </Fragment> )} </Fragment> ); }; Profiles.propTypes = { getProfiles: PropTypes.func.isRequired, profile: PropTypes.object.isRequired, }; const mapStateToProps = (state) => ({ profile: state.profile, }); export default connect(mapStateToProps, { getProfiles })(Profiles);④ ProfileItemコンポーネント (Profilesの一部)
components/profiles/ProfileItem.jsimport React, { Fragment } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; const ProfileItem = ({ profile: { user: { _id, name, avatar }, status, skills, }, }) => { return ( <Fragment> <div className="profile bg-light"> <img src={avatar} alt="" className="round-img" /> <div> <h2>{name}</h2> <ul> {status.slice(0, 2).map((status, index) => ( <li key={index}>{status}</li> ))} </ul> <Link to={`/profile/${_id}`} className="btn btn-primary"> View Profile </Link> </div> <ul> {skills.slice(0, 4).map((skill, index) => ( <li key={index} className="text-primary"> <i className="fas fa-check" /> {skill} </li> ))} </ul> </div> </Fragment> ); }; ProfileItem.propTypes = { profile: PropTypes.object.isRequired, }; export default ProfileItem;3. プロフィールページ
① profileフォルダ生成 -> Profileコンポーネント生成。
② Profileページのルートを設置。
src/App.js//... import Profiles from './components/profiles/Profiles'; import Profile from './components/profile/Profile'; //... <Route exact path="/profiles" component={Profiles} /> + <Route exact path="/profile/:id" component={Profile} /> //...③ Profileコンポーネント(Profileページ)
match: IDとマッチしてるか検証
components/profile/Profile.jsimport React, { Fragment, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; import Spinner from '../layout/Spinner'; import ProfileTop from './ProfileTop'; import ProfileAbout from './ProfileAbout'; import { getProfileById } from '../../actions/profile'; const Profile = ({ getProfileById, profile: { profile, loading }, auth, match, }) => { useEffect(() => { getProfileById(match.params.id); }, [getProfileById, match.params.id]); return ( <Fragment> {profile === null || loading ? ( <Spinner /> ) : ( <Fragment> <Link to="/profiles" className="btn btn-light"> Back To Profiles </Link> {auth.isAuthenticated && auth.loading === false && auth.user._id === profile.user._id && ( <Link to="/edit-profile" className="btn btn-dark"> Edit Profile </Link> )} <div class="profile-grid my-1"> <ProfileTop profile={profile} /> <ProfileAbout profile={profile} /> </div> </Fragment> )} </Fragment> ); }; Profile.propTypes = { getProfileById: PropTypes.func.isRequired, profile: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, }; const mapStateToProps = (state) => ({ profile: state.profile, auth: state.auth, }); export default connect(mapStateToProps, { getProfileById })(Profile);④ ProfileTopコンポーネント(Profileページの一部)
components/profile/ProfileTop.jsimport React from 'react'; import PropTypes from 'prop-types'; const ProfileTop = ({ profile: { status, website, social, user: { name, avatar }, }, }) => { return ( <div class="profile-top bg-primary p-2"> <img class="round-img my-1" src={avatar} alt="" /> <h1 class="large">{name}</h1> <p class="lead">{status}</p> <div class="icons my-1"> {website && ( <a href={website} target="_blank" rel="noopener noreferrer"> <i class="fas fa-globe fa-2x" /> </a> )} {social && social.twitter && ( <a href={social.twitter} target="_blank" rel="noopener noreferrer"> <i class="fab fa-twitter fa-2x"></i> </a> )} {social && social.facebook && ( <a href={social.facebook} target="_blank" rel="noopener noreferrer"> <i class="fab fa-facebook fa-2x"></i> </a> )} {social && social.linkedin && ( <a href={social.linkedin} target="_blank" rel="noopener noreferrer"> <i class="fab fa-linkedin fa-2x"></i> </a> )} {social && social.youtube && ( <a href={social.youtube} target="_blank" rel="noopener noreferrer"> <i class="fab fa-youtube fa-2x"></i> </a> )} </div> </div> ); }; ProfileTop.propTypes = { profile: PropTypes.object.isRequired, }; export default ProfileTop;⑤ ProfileAboutコンポーネント(Profileページの一部)
components/profile/ProfileAbout.jsimport React, { Fragment } from 'react'; import PropTypes from 'prop-types'; const ProfileAbout = ({ profile: { bio, skills, user: { name }, }, }) => { return ( <div class="profile-about bg-light p-2"> {bio && ( <Fragment> <h2 class="text-primary">{name}'s Bio</h2> <p>{bio}</p> <div class="line"></div> </Fragment> )} <h2 class="text-primary">Skill Set</h2> <div class="skills"> {skills.map((skill, index) => ( <div key={index} className="p-1"> <i className="fas fa-check" /> {skill} </div> ))} </div> </div> ); }; ProfileAbout.propTypes = { profile: PropTypes.object.isRequired, }; export default ProfileAbout;