概要
この記事では、2ヶ月間でゼロから1人でWebアプリケーションを作ってみた際にやったことなどをノウハウとしてまとめています。
だいたい以下のことを書いてます。
- どんな流れでWebアプリを個人で作ってリリースまでもっていったか
- その時にどういうことをしないといけないか
- Reactでどうやってそこそこ本格的なWebアプリを作るか
- バックエンドとどんな感じで連携をしているのか
作ったもの(EveryChart)
誰でも好きな口コミ評価をつくれるというサイトです。
GAE/Go上で動いており、フロントサイドはReact + Redux + TypeScriptを使ってます(詳細は後述)。
【拡散希望】誰でも好きな口コミ評価を作ってシェアできるWebサービス「EveryChart」を作成しました!こんな感じのレーダーチャートの評価ページを誰でも作ることができるってサービスです。よければ使ってみて下さい〜(^-^)/https://t.co/A4yVi96uPH #EveryChart pic.twitter.com/JQTQtZqsBM
— branch@個人アプリ開発者 (@br_branch) August 31, 2019
また、ソースコードはGitHubにて公開しています。
※ セキュリティ上の理由でバックエンド側は中途半端な状態で公開してますが、トップページまでは動かせます。
なお、本記事では以下の内容を扱ってます。
-
フロントエンド
- React v16
- Redux v4
- TypeScript v3.5
- Webpack v4
- chart.js v2
- Javascript Load Image v2
-
バックエンド
- Google Cloud Platform (GCP)
- GAE / Go1.9
- Echo v3
- Twitter API
- Firestore API
- Google Cloud Storage API
また、コマンドの実行環境は、Mac OSX(10.14.5) です。
どんな人が作ったのか(自己紹介)
普段はJava or Goを使ったバックエンド開発をメインで行っています。
ただ、最近はグループマネージャーとして複数プロジェクトを見たりといった管理業務がメインになってます(プレイングマネージャーとして設計や実装などもそこそここなしてます)
フロントサイドは、7年前にはやってました。ただその頃はまだHTML4で、業務システムがメインだったのでリッチなこともしておらず、主にjQueryを使って作成をしていました。
というわけで最近のフロントエンド開発でいうとまったくの未経験者です。
Webアプリを作った理由
- 最近のフロントエンドを勉強したかった
- というかゼロからWeb開発したくなった
- いずれ独立・起業も視野にいれた副業を始めるためのポートフォリオになるものを作りたかった
というわけで、前置きが長くなりましたがここから先が本題になります。
本題
上記のように、勉強+ポートフォリオとなるものを作るというのが動機だったので、最初から 結構ガチなサイト を作ろうと考えてはいました。更には、作るときには以下の目標を立てながら開発をしました。
- なるべく短期間で作る
- ポートフォリオとして、バックエンド側は仕事のノウハウも活かせるものを作る
- かつ、バックエンドでも初めてのことに挑戦する
- 運用コストはほぼゼロにする
- 毎日2時間だけ作業にあてる
結果としては、短期間かどうかはわかりませんが、2019/7/7に開始し、2018/8/31にリリースまで持っていけました。ちょうど8週間、約2ヶ月で、勉強しながらだったのでぼくなりには満足してます🥳
2ヶ月間のスケジュール
だいたい以下のスケジュール感で作成していきました。
- 何をつくるかとアーキテクチャなどもろもろ決める(2019/7/7:1日)
- 開発(バックエンド&フロントエンド)(2019/7/8〜2019/8/23:47日)
- 画像などのリソース作成 (開発と並行)
- ステージング環境での検証(2019/8/24〜2019/8/28:5日)
- 本番環境での最終確認(2019/8/29〜2019/8/30:2日)
- リリース(2019/8/31:1日)
何を作るか決める
最初にやったことは当然ながら何を作るかの検討でした。
とはいっても、以前から「こういうの欲しいなぁ」というアイデアはメモしたりしていて、そのネタ帳からひっぱってくるだけだったのですぐ決まりました。以前から**「よく行くお店とか、感動した小説とかを自分なりにランク付けできるようなアプリがあるといいなぁ」**って思ってたので、それを作ることにしました。
なるべく最小限の機能でリリース
大枠はきまったので、次はどういう機能を入れるかを検討します。平日の通勤時間を使って検討をしました。
こういうのを考えてる時が一番楽しいですね。
ただ、機能を入れすぎると短期間では作成ができないので、以下だけを作ることに。
-
アカウントの管理(Twitterでの認証)
- TwitterAPIでの認証・認可
- ログイン/ログアウト
- 登録と退会
-
評価ページをグループ化できる機能(ノート)
- 画像アップロードできる
- 説明文を書ける
- 編集と削除ができる
-
評価ページの作成
- 好きなチャートが作れる
- チャートはアニメーションで表示される
- 画像アップロードできる
- 説明文を書ける
- 編集と削除ができる
- 評価ページにコメントができる
- 他の人も評価を投稿できる
-
その他細かい機能
- 利用規約とプライバシーポリシー
- トップページの説明
- 全体セキュリティはしっかりと作り込む
だいたいざっくりと1つ3日(6時間)と見積もりました。そして重複してる機能は流用できると想定して、39日くらいでできるかなぁとか考え、更には**ウォーズマン理論を応用することで7月中にリリースできるんじゃないか**と考え、それを目標に開発を開始しました。
結果的には色んな要因でウォーズマン理論の応用は断念し、かつ最初の予定より2週間遅れちゃいましたが。
やっぱり、自分がウォーズマンではないと途中で気づいてしまったのが痛かったです。
何に挑戦するか
続いて、何に挑戦するかを決めます。これも念入りに検討したというよりは「前から興味はあったもの」を取り入れることにしました。
フロント側は、Reactを使う予定では最初からいました。Vue.jsやAnglarにも興味はあったのですが、Reactはライブラリが豊富って聞いてたのでそれで選んだ程度の感じです(次はVue.jsも使ってみたい)。
それで色々と調べていたら、最近では ReactとReduxを併用してState管理をするらしいということを知ったので、 React+Redux入門 とかを読みつつ、それも導入することにします。ついでにTypeScriptで書こうとも安易な気持ちで思い、以下の挑戦をすることにしました。
-
フロントエンド
- React + Redux + TypeScriptでフロント側を作る
- Webpack4を使ってみる
-
バックエンド
- バックエンドは基本構成は今回は実績のあるもので(GAE/Go1.9)
- ただFirestore Nativeモードを使ってみる
- クリーンアーキテクチャとDDDにも挑戦してみる
アーキテクチャ
こんな感じの検討をしました。
ディレクトリ構成
ディレクトリ構成はこんな感じです。
ただ、下の「反省点」でも書いたのですがフロント側のディレクトリ構成は失敗でした。
まあ、それも経験ということで採用した構成をそのまま載せます。
(反省点の箇所にこうしたらよかったというのは書いてます)
EveryChart
├── backend # バックエンド。最終的にはこの中のものがデプロイされる
│ ├── src # ソースディレクトリ
│ │ └── project # ルート
│ │ ├── core # サーバーの基本部分を扱ったパッケージ
│ │ ├── client # TwitterAPIやFirestoreなど、外部サービスと連携するためのクラスをまとめるパッケージ
│ │ | ├── foon # Firestore APIを扱うパッケージ
│ │ | ├── oauth # Twitter APIを扱うパッケージ
│ │ | ├── session # Session API を扱うパッケージ
│ │ | └── storage # Google Cloud Storage API を扱うパッケージ
│ │ ├── errors # errorを扱ったパッケージ
│ │ ├── handler # エンドポイントの処理をまとめたパッケージ
│ │ ├── mapper # JSONファイルとドメインモデルをマッピングするクラスを集めたパッケージ
│ │ ├── middlewares # サーバーの振る舞いを定義するクラスを集めたパッケージ
│ │ ├── model # ドメインモデルを集めたパッケージ
│ │ ├── persistence # データベースへ保管するEntityとレポジトリを定義したパッケージ
│ │ | ├── data # データベースのEntityを表現したパッケージ (モデルのマッピングも行う)
│ │ | └── repository # データアクセスを扱うパッケージ
│ │ ├── usecase # システムの振る舞いをまとめたパッケージ
│ │ ├── util # ユーティリティクラスをあつめたパッケージ
│ │ ├── vendor # depの依存パッケージが格納されるパッケージ
│ │ ├── Gopkg.toml # depの設定ファイル
│ │ └── main.go # バックエンド側のエントリポイント
│ ├── static # 静的ファイルの置き場
│ │ ├── images # faviconなどの画像置き場
│ │ └── js # webpackでコンパイルしたファイルが格納される
│ │ └── bundle.js # webpackの生成ファイル
│ ├── template # htmlのGo テンプレートを配置するディレクトリ
│ │ ├── layout # ベースとなるレイアウトを定義するパッケージ
│ │ ︙
│ ├── .envrc # direnvの設定ファイル
│ └── hogehoge.yaml # backendの設定ファイル。ローカル用/ステージング用/本番用を用意
└── front # フロントエンド側。この中のものがwebpackでコンパイルされ、 backend/static/js/bundle.js に格納される
├── node_modules # node.jsの依存パッケージが格納される
├── src # ソースファイルの配置場所
│ ├── app # 各機能ごとのコンポーネントを表現。ただここのパッケージ構成は失敗だった。。
│ │ ├── common # 共通のコンポーネントを表現
│ │ ├── xxxx # 各機能
│ │ │ ├── xxxComponent.tsx # UI部品
│ │ │ ├── xxxActions.ts # イベントのアクションを定義
│ │ │ ├── xxxContainer.ts # UIの振る舞いを定義
│ │ │ └── xxxState.ts # UIの状態を定義
│ │ ︙
│ │ ├── component.tsx # 基礎とのあるレイアウト
│ │ ├── routerActions.ts # 基本レイアウトのアクション
│ │ ├── routerContainer.ts # 基本レイアウトのコンテナ
│ │ └── routerState.ts # 基本レイアウトの状態を表したクラス
│ ├── client # backendとの通信を行うクラスを扱ったパッケージ
│ ├── model # クライアント側で扱うモデルを扱ったパッケージ
│ ├── sample # 練習用のがそのまま残ってるだけ
│ ├── types # 一部TypeScriptに対応していないライブラリの定義をするためのパッケージ
│ ├── utils # ユーティリティクラス
│ ├── consts.ts # 定数を集めたクラス
│ ├── eventDispacher.ts # 共通で呼び出したいイベントを定義
│ ├── index.css # 共通のCSS
│ ├── index.tsx # フロント側のエントリポイント
│ ├── registerServiceWorker.ts # service-worker.js を登録するためのスクリプト
│ └── store.ts # ReduxのStore
├── package.json # パッケージ管理設定ファイル
├── tsconfig.json # TypeScriptの設定ファイル
├── tslint.json # TypeScriptの静的解析の定義ファイル
└── webpack.config.js # Webpackの設定ファイル
全体の流れとしては、以下のような感じです。(細かい部分はちょっと違うけどだいたいこんな感じ)
実際ローカルで動かす
実際の動きは、GitHubから落としてきて確認できます。
バックエンド側は公開しすぎちゃうとセキュリティ的にもよろしくないんで、コアとなる部分は消してしまっていますが、トップページまでなら見ることができるかと思います。
セットアップ
以下をMacの中に入れます。
- Go v1.9 (環境構築(Qiita))
- Dep (環境構築(Qiita))
- Direnv(環境構築(Qiita))
- Gcloud(環境構築(Qiita))
- Goapp (環境構築(Qiita))
- Firebase emurator (環境構築(Qiita))
# 上の必要なツール類は入れている状態
$ git clone https://github.com/brbranch/EveryChartSample --recursive
$ cd ./EveryChartSample/backend
$ direnv allow # direnvを有効にする
$ cd ./src/project
$ dep ensure # 依存ライブラリのDL
# firestore emuratorの起動
$ gcloud beta emulators firestore start --host-port=localhost:8915
# サーバーの起動(別タブなどでターミナルを開く)
$ goapp serve local.yaml
あとは、 http://127.0.0.1:8080
をブラウザから叩くとトップページが表示されると思います。
開発開始
そんなわけで、構成などもろもろ検討したところで開発を開始していきます。ここからはハイライトだけ記載していきます。
バックエンド側の開発
一番最初は、フロントではなく慣れてるバックエンド側から作成をしていきました。
構成は最初から上記のような形で、最初モデルを考え、ユースケースを記載してから、Handlerでエンドポイントを作成し、更に処理を書いていくという流れです。
Twitterで認証できるようにする
まずはログインができないとその先も作りづらかったので、Twitter連携から作成しました。
Twitter Developer Platform からアプリの登録をし、アプリのトークンとシークレット情報を入手します。
(そのあたりのやり方は https://qiita.com/kngsym2018/items/2524d21455aac111cdee が詳しいです)
一点補足すると、TwitterのOAuthを利用する際には、アプリのコールバックURLを指定することが必須となっています。
ただ、このコールバックURLは http://127.0.0.1:8080
といったものでも大丈夫なので、それを指定しておきます。
その部分の実装はこんな感じ。
type AuthHandler struct {
}
func (a *AuthHandler) Handle(e *echo.Group) {
// ... 中略...
e.GET("/auth/twitter/:id", a.authTwitter)
}
func (a *AuthHandler) authTwitter(e echo.Context) error {
ctx := core.NewContext(e)
client := oauth.NewAuthClient(oauth.Twitter, ctx)
requestUri, err := client.GetAuthUrl(e.Param("id"))
if err != nil {
return core.ErrorHTML(e, err)
}
return e.Redirect(http.StatusFound, requestUri)
}
type TwitterAuth struct {
Context core.Context
}
type TwitterAccount struct {
ID string `json:"id_str"`
Name string `json:"name"`
RefID string `json:"screen_name"`
Description string `json:"description"`
ProfileImageURL string `json:"profile_image_url_https"`
Email string `json:"email"`
}
func (t *TwitterAuth) Connect() *oauth.Client {
return &oauth.Client{
TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token",
ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authenticate",
TokenRequestURI: "https://api.twitter.com/oauth/access_token",
Credentials: oauth.Credentials{
Token: os.Getenv("TWITTER_AUTH_TOKEN"),
Secret: os.Getenv("TWITTER_AUTH_SECRET"),
},
}
}
func (t *TwitterAuth) GetAuthUrl(anonymousId string) (string, error) {
config := t.Connect()
client := urlfetch.Client(t.Context)
host := t.Context.Request().URL.Host
url := fmt.Sprintf("https://%s/authc/twitter", schema, host)
if os.Getenv("ENVIRONMENT") == "local" {
url = "http://127.0.0.1:8080/authc/twitter"
}
rt, err := config.RequestTemporaryCredentials(client, url, nil)
if err != nil {
return "", t.Context.WrapErrorf(err, "failed to create request.")
}
sess, err := session.NewSession(t.Context)
if err != nil {
return "", t.Context.WrapErrorf(err, "failed to open session.")
}
sess.PutString(AnonymousIdSession, anonymousId)
sess.PutString(twitterRequestToken, rt.Token)
sess.PutString(twitterRequestSecret, rt.Secret)
sess.Save()
return config.AuthorizationURL(rt, nil), nil
}
これでTwitterに認可リクエストを投げるためのURLを作成しています。
そして、ユーザーが認可をし、指定したコールバック先に認証トークンが送られてきます。それが以下の場所です。
onst AuthSessionKey string = "AuthResultMessage"
type OAuthCallbackHandler struct {
}
func (a OAuthCallbackHandler) Handle(e *echo.Group) {
e.GET("/twitter", a.authTwitter)
}
func (OAuthCallbackHandler) authTwitter(e echo.Context) error {
ctx := core.NewContext(e)
verifer := e.QueryParam("oauth_verifier")
client := oauth.NewAuthClient(oauth.Twitter, ctx)
account , err := client.GetAccount(verifer)
if err != nil {
return handleError(e, ctx, err)
}
service := usecase.NewLoginService(ctx)
if ac , err := service.LoginOrSignup(account); err != nil {
return handleError(e, ctx, err)
} else {
ctx.Infof("login (userID: %s)", ac.ID)
return e.Redirect(http.StatusFound, "/home")
}
return e.Redirect(http.StatusFound, "/top")
}
func (t *TwitterAuth) GetAccount(verifier string) (*LinkedAccount, error) {
token, err := t.GetAccessToken(verifier)
if err != nil {
return nil, err
}
oc := t.Connect()
client := urlfetch.Client(t.Context)
v := url.Values{}
v.Set("include_email", "true")
resp, err := oc.Get(client, token, "https://api.twitter.com/1.1/account/verify_credentials.json", v)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return nil, errors.New("Twitter is unavailable")
}
if resp.StatusCode >= 400 {
return nil, errors.New("Twitter request is invalid")
}
twitter := &TwitterAccount{}
err = json.NewDecoder(resp.Body).Decode(twitter)
if err != nil {
return nil, t.Context.WrapErrorf(err, "failed to decode account")
}
return &LinkedAccount{
UniqueID: fmt.Sprintf("twitter:%s", twitter.ID),
Type: "twitter",
ID: twitter.RefID,
Name: twitter.Name,
ImageURL: twitter.ProfileImageURL,
Description: twitter.Description,
Email: twitter.Email,
}, nil
}
func (t *TwitterAuth) GetAccessToken(verifier string) (*oauth.Credentials, error) {
config := t.Connect()
sess, err := session.NewSession(t.Context)
if err != nil {
return nil, t.Context.WrapErrorf(err, "failed to open session.")
}
token := sess.GetString(twitterRequestToken)
secret := sess.GetString(twitterRequestSecret)
if token == "" || secret == "" {
return nil, exception.INVALID_SESSION
}
client := urlfetch.Client(t.Context)
at, _, err := config.RequestToken(client, &oauth.Credentials{
Token: token,
Secret: secret,
}, verifier)
defer func() {
sess.Delete(twitterRequestSecret)
sess.Delete(twitterRequestToken)
sess.Save()
}()
if err != nil {
return nil, t.Context.WrapErrorf(err, "failed to request token.")
}
sess.PutString(twitterOAuthSecret, at.Secret)
sess.PutString(twitterOAuthToken, at.Token)
return at, nil
}
あとは、実際にログイン/サインインの処理を行うことでデータを永続化し、その後の認証に利用をします。
Firestore Native ModeのAPIをGoで利用する
今回、データの永続化としてはFirestoreを利用しました。
Firestoreは、もともとはFirebaseの1つのサービスだったのですが、いつからかGCPに取り入れられ、今後DataStoreと置き換わる予定になっています。DataStore互換モードとネイティブモードがあります。料金設定は変わりません。
FirestoreとMemcacheを連携するライブラリがない
ただ、Firestoreのネイティブモードはランニングコストを抑えるという点ではひとつ欠点があり、Memcacheとの連携ライブラリがまだないというのがあります(どこかで、 Firestore自身がキャッシュする
、みたいな話も見たのですが、確認した限りは毎回取得のたびに無料枠を圧迫していってるような感じでした)。
DataStore互換モードだと、goonのようにMemcacheと連携してコストを抑えてくれるライブラリがあるんですけどね・・・。
そんなわけで、仕方ないので作成しました。
Qiita
https://qiita.com/kazuked/items/0187ec99c19ae1076a12
(正直これを作るのに時間を使ってしまい、リリース日が予定より遅れてしまいました(´・ω・`))
クライアント側の開発
バックエンドはまあそんな感じで、続いてはクライアント側です。
エントリポイントの作成
まずはエントリポイントである index.tsxの作成を行います。
また、今回はReduxを使うので、storeへの登録といったものも行えるようにしておきます。
Reduxについては、こちらの記事が詳しいです。
Redux入門【ダイジェスト版】10分で理解するReduxの基礎
https://qiita.com/kitagawamac/items/49a1f03445b19cf407b7
ただ、Reduxをそのまま使うのは大変らしいので、今回はtypescript-fsaを使ってます。
このあたりは、以下を参考にしました。
関東最速でReact+Redux+TypeScriptなアプリを作る
https://qiita.com/IzumiSy/items/b7d8a96eacd2cd8ad510#fn1
//
const history = createBrowserHistory()
export const store = createStore(
Store(history),
applyMiddleware(thunk, routerMiddleware(history))
)
// Material-UIテーマカスタマイズ
const theme = createMuiTheme({
// 中略
});
// FontAwesomeの追加
library.add(fab, fas, far, faTwitter, faCoffee, faHeart, faComment, faCheckSquare, faExclamation, faExclamationCircle, faChartArea, faInfoCircle, faTrash, faUserLock, faFileAlt, faStar);
ReactDOM.render(
// Storeの使用
<Provider store={store}>
// Material UIのテーマの設定
<MuiThemeProvider theme={theme} >
// ルーティングの設定
<ConnectedRouter history={history}>
<Root/>
</ConnectedRouter>
</MuiThemeProvider>
</Provider>,
document.getElementById('app')
);
storeはこんな感じです。
export type AppState = {
login: LoginState,
router: RouterState,
// Root部分
root: RootState,
// 省略
};
export default (history: any) => combineReducers<AppState>({
router: connectRouter(history),
login: loginReducer,
root: rootReducer,
// 省略
}
);
ルーティング
ルーティングはreact-routerを利用します。
これを使うことで、URLによって表示の出し分けということができるようになります。
return (
<div className={classes.root}>
<CssBaseline />
<AppBar position="fixed" color="default" className={classes.appBar}>
// 省略:共通ヘッダーの設定
</AppBar>
<main className={classes.content}>
<div className={classes.toolbar} />
{renderBody()}
<div className={classes.hr}> </div>
<div className={classes.footer}>
// 省略:共通フッターの設定
</div>
</main>
// 省略: 通知ダイアログの設定
</div>
);
function renderBody() {
// エラーが存在してたら、他の描画はやめてエラーページを表示
if (errorJson) {
const err = JSON.parse(errorJson);
return renderError(err.error);
}
// ルーティング
return renderRouter();
}
function renderRouter() {
return (
// ルーティングの部分
// pathに指定したパターンのコンポーネントのみを描画する
<Switch>
<Route exact path="/" render={({match}) => (<Login/>)} />
<Route exact path="/top/terms" render={({match}) => (<Terms/>)} />
<Route exact path="/top/policy" render={({match}) => (<PrivacyPolicy/>)} />
<Route exact path="/top/info" render={({match}) => (<Information/>)} />
// 省略
</Switch>
);
}
その後の作成の流れ
フロント側は、以下の流れで作成を行いました。
- Actions / State / Container / Componentをそれぞれ作成する
- Actionsにそのコンポーネントのアクションを定義
- Stateで、アクションごとの状態変更を定義
- Containerで、コンポーネントのふるまいを定義
- ComponentでUI部品を定義
- 実際に取り扱うデータ部分はDataとModelに定義
- ContainerまたはStateでModelを呼び出し、データの加工を行う
- Containerで呼び出した場合は加工後のデータをActionで送る
Action / State / Container / Component
の部分は、Reduxのやり方そのものかなって思います。
ただ、それだけだとちょっと使いづらかったので、バックエンドで表現してるモデルの一部はフロント側でも表現をしました。
(反省点で記載しますがそのやり方のデメリットもあったし、もっと効率の良いやり方もあったのかもしれませんが)
Reducerというのは、Reduxの中でいい感じにアレしてくれるアレで、Stateのファイル内で各自定義してます。
以下に、例を一部抜粋します。
const actionCreator = actionCreatorFactory();
// Action
export const commentEditActions = {
changeComment: actionCreator<string>('COMMENTEDIT_ACTION_CHNAGECOMMENT'),
};
// State
export interface NotebookCommentEditState {
myComment: NotebookComment
visible: boolean
edit: boolean
}
// デフォルトのState(初期値)
const initialState: NotebookCommentEditState = {
myComment: undefined,
visible: false,
edit: false,
};
// Reducer
export const notebookCommentEditReducer = reducerWithInitialState(initialState)
.case(commentEditActions.changeComment, (state, value) => {
const model = new NotebookCommentModel(state.myComment, null);
model.changeComment(value);
return {...state,edit:true, myComment: {...model.data()}}
})
// データの定義
export interface NotebookComment {
commentId: string
// 省略
hasComment: boolean,
created: number
isNew: boolean
}
export class NotebookCommentModel {
private page: NotebookPage;
private comment: NotebookComment;
constructor(comment: NotebookComment, parent: NotebookPage) {
this.comment = comment;
this.page = parent;
}
changeComment(comment: string) {
this.comment.comment = comment;
this.comment.hasComment = this.hasComment();
}
data(): NotebookComment {
return this.comment;
}
}
// これはComponentから呼ばれるアクション(Event)
export interface Actions {
changeComment: (value: string) => Action<string>;
}
// 上記のActionをPropsに変換してる
function mapDispatchToProps(dispatch: Dispatch) {
return {
// 上記のActionsの定義
changeComment: (value: string) => {
// ここで、Dispatchされる
dispatch(commentEditActions.changeComment(value));
}
};
}
// stateをReactのPropsに変換してる
function mapStateToProps(appState: AppState) {
return Object.assign({}, appState.notebookComment);
}
// 2つのプロパティをComponentに渡してる
export default connect(mapStateToProps, mapDispatchToProps)(Component);
// コンポーネント内の個別Styleを定義(Material-uiのメソッド)
const useStyles = makeStyles((theme: Theme) =>
createStyles({
notice : {
color: "red",
fontSize: "0.8em"
}
}),
);
// コンポーネントの独自プロパティを定義
interface Props {
page: NotebookPage
}
// ActionsとNotebookCommentEditStateのプロパティをマージしてる
type OwnProps = Props & Actions & NotebookCommentEditState;
// FunctionalComponent
export const Component: React.FC<OwnProps> = (props: OwnProps) => {
function showComment() {
if(permission.hasCommentPermission()) {
return (
<Textarea title="コメント"
value={props.myComment.comment}
onChange={(v) => {
// イベントをContainerに送っている
props.changeComment(v);
}}/>
)
}
}
return (
<Dialog
// 省略
>
<DialogContent >
{showComment()} // 上の関数呼び出し
</DialogContent>
</Dialog>
);
}
バックエンドからのデータを取り込む
このシステムでは、2通りのやり方でバックエンドからのデータを受け取っています。
1. レンダリング時にJSONのデータをscriptタグ内に埋め込む
topなど、画面を描画する最初のデータはレンダリング時にJSONの文字列としてscriptタグに埋め込むという方法で渡しています。
具体的には、こんな感じですね。
https://github.com/brbranch/EveryChartSample/blob/master/backend/templates/auth/top.html#L2
<!-- レンダリング時にはこんな感じになります -->
<script id="data-register" type="text/plain" data-json='{"register": null}'></script>
<script id="data-login" type="text/plain" data-json='{ "error" : ""}'></script>
2. FetchAPIを使って非同期で取得する
昔は非同期といえばjQueryを使ってましたが、最近はFetch APIがおよそどのブラウザもサポートをしています(IE以外)。
というわけで、そちらをContainerから呼び出して取得をしています。
そのための専用のクラスを作成して、そのクラス経由で毎回呼び出しをするようにしました。
export interface FetchError {
code: number
text: string
};
export class UrlFetch {
private static _instance : UrlFetch;
private etag: string = "";
private token: string = "";
private constructor() {
}
private handle(response: any): any {
if (response.ok) {
return response;
}
throw Error("" + response.status);
}
get(path: string, callback: (json:any, error: FetchError) => void) {
EventDispacher.instance.showProgress();
fetch(path, {
method: 'GET',
credentials: 'include', })
.then(this.handle)
.then(res => res.json())
.then(json => callback(json, null))
.catch(error => {
EventDispacher.instance.showToast(this.handleErrorStr(error.message));
callback(null, {code: parseInt(error.message), text: ""});
})
.finally(()=>{
EventDispacher.instance.hideProgress();
})
}
}
Chart.jsをReactに組み込む
このシステムでは評価をするのにChart.jsを利用しています。
本当はRechartsというReact用のチャートライブラリを利用する予定だったのですが、実現したいことができないことがわかったので諦めました。
ただChart.jsはReactのコンポーネントではないので、うまい具合にコンポーネントに組み込まなければいけません。
とはいえ、そんな難しくはなく、単に呼び出せばいいだけなのですが。
export class RadarChart extends React.Component<RadarProps> {
// 作成したElementを操作するための変数
private canvasRef: React.RefObject<HTMLCanvasElement> = React.createRef();
private data: RadarItem[] = [];
componentDidMount() {
this.update(this.props);
}
private update(props: RadarProps) {
const options: any = {
type: 'radar',
// 省略
}
// renderで行ったcanvasが取得できたらDOM操作を行う
if (this.canvasRef.current) {
const ctx = this.canvasRef.current.getContext("2d");
// chart.jsを呼び出す
var myChart = new Chart(ctx, options);
}
}
render() {
return (
<canvas className={this.props.className} style={style} ref={this.canvasRef}></canvas>
)
}
}
レスポンシブ対応
こちらも、そんな難しくはなかったです。EveryChartはReact Material-Uiを利用しており、そこにある「Container」と「Grid」を使ってとりあえずのレスポンシブ対応をしました。
ContainerとGridについては、こちらにわかりやすく乗ってました。
【Material-UI】v4で追加された気になる新機能
https://qiita.com/tag1216/items/ab30da234dca40751f31
Material-UIでGridレイアウトを試す
https://qiita.com/vimyum/items/5ba06ca166ebe4992617
ステージング環境での検証とリリース
さて、そんな感じでどんどんと作成していき、およそ機能は完成したので、8月終わり頃からステージング環境にデプロイをして検証を行い始めました。
goappの場合、デプロイはコマンドで一発です。
$ cd ./backend
$ goapp deploy app-staging.yaml
その後は主に以下の観点で検証を行っていきました。
- レイアウトが崩れてないか
- セキュリティ的な問題が発生してないか
- 見えてはいけないものが見えてないか
- 見えるべきものが見えているか
- 一般的な脆弱性攻撃の対策がされているか
- データの不整合が発生しないか
それらを一通り確認後、8/31(土)に本番環境に上げ、最終確認の後にリリースをしました。
とりあえずはやり遂げた!(`・ω・´)
React + Reduxで作ってみた感想
とりあえず、ひとつ思ったのは**「最近のフロントエンドってめちゃくちゃ進化してるなぁ」**ってことでした。もうね、やばいですね。
最初はReduxがよく理解できず戸惑い続けていて、 すんごい大げさなやり方でHello Worldを打ってるような気分 になってたりして全然はかどらなかったのですが、慣れてくるととても高速に開発することができました。
特にライブラリが充実してるのがいいですね。
反省点・失敗した点
次に、作ってて失敗したとか思った点について書き連ねます。
パッケージ構成間違えた
バックエンドもちょっと間違えてるのですが、特にフロントエンドは失敗したなぁと思いました(´・ω・`)
機能ごとにフォルダを作って「Action/State/Container/Component」を作成していましたが、これは完全に失敗ですね。。
むしろ以下のような構成で作るべきでした。
├── actions
│ ├── topActions.ts
│ ︙
├── containers
│ ├── topContainer.ts
│ ︙
├── components
│ ├── topComponent.ts
│ ︙
├── states
│ ├── topState.ts
│ ︙
├── index.tsx
└── store.ts
(他の人達はみんなそうやってたから素直にそうすればよかった…)
理由は、そもそも振る舞いとUIを疎結合にするために「Action/State/Container/Component」に分けてるんだから、同じフォルダ(≒パッケージ)に入れて密な結合を思わせるような構成は避けるべきでした。
最初は「関連してるんだから同じところに入れるべきでしょ」とか思ってたのですが、結果的にわかりづらくなったし、再利用もちょっとしづらかったです。
Action/State/Container/Component/Data/Modelのやり方がよかったのかよくわからない
他のサイトとかで見るとそんなやり方してないんで、あんまりよくないパターンなんじゃないのかなぁとか思いながら作ってました。
理由として、Reduxは浅い比較 なので、Stateの中にインタフェースの入れ子状態にすると変更を検知しない可能性があるそうです。
ReduxのStateが変更されたのに再レンダリングされない問題
https://qiita.com/yasuhiro-yamada/items/aebda0dff79a70eb71d7
上記の記事を見ると「メモリ上のID(=ポインタの番地)が変わらないと変更を検知しない」と書いてます。
対策としては、単純にポインタ参照する箇所(JavaScriptだと配列とかマップとか)を作り直せばいいわけなのですが、それはとりもなおさず「変更範囲が広くなる」ことを表します。
つまり、Stateを以下の構造とした場合
interface xxxxState {
user: User
}
interface User {
name: string
address: string
accountId: string
}
この xxxState#user のnameを変更したい場合、その変更をReduxが検知するためには、中のインスタンスを作り直す必要があるわけですよね。
すると、本来は変更してない address
や accountId
の部分も変更したとみなされて、Reactの仮想Domは最構成しなおすんjないのかなぁ…と。(実際どうなのかはわかってないのですが。。。)
最後に
すごく長く書いちゃいましたが、とりあえず作りきれたのでよかったです。とはいえ、作って終わりというわけでもないのでちょくちょくアップデートはしていこうと思います(`・ω・´)