LoginSignup
15
5

More than 5 years have passed since last update.

Next.js/Koa.js/AWS-ECSでWebアプリの開発レポート

Last updated at Posted at 2018-12-07

DMM.com Advent Calendar 2018 8日目の記事です。

Next.jsでWebアプリを開発したのでその知見の共有と忘備録になります。

※ 本投稿はReact-Reduxの知識・技術がある方に向けた内容になっています。

Next.js についておさらい

  • とにかく楽に React の webアプリを開発できる
  • ReactをSSR (Server-Side Rendering) し、babelで書けてビルドもしてくれて、且つユニバーサルアプリケーションを作成できるOSSのフレームワークです
  • 環境開発をすっとばしていきなりReactの記述から入れる体験を是非
  • バージョンが上がってNuxt.jsにも負けない機能が備わってきた気がします

気になった人、まだ触ってない人は公式ページへGo

本件でやったこと考えたこと

制作要件

  • 社内業務用管理画面として
  • 先にサーバサイドがAPI開発で動いていた Golang + AWS (ECS/Fargate)
    • API駆動
    • 管理画面もAWS/ECSにデプロイ
  • ログイン認証・フォーム画面である程度作り込み (フロントバリデーション) が必要
  • AWS/CloudFrontでマルチドメイン及び、 /hoge/admin が管理画面のアプリケーションルート

これを受けて以下のような技術要件にしました。

使用技術・ライブラリ (この記事で触れるもの)

基本100%Javascriptで記述できるよう組み立てました。
アプリケーション本体はNext.js、その土台としてKoa.jsでWebサーバを立てています。
なぜKoa.jsを採用しているか詳しい話は後述で。

# Webサーバ
- Koa.js
    - koa-router
    - koa-simple-healthcheck

# アプリケーション
- Next.js
    - next-routes
    - recompose
        - 一部の例外を除いて、SFCでコンポーネントを記述しました
    - redux/Redux-saga
        - redux-logger
        - redux-form/react-dropzone
        - sweetalert
    - styled-jsx
    - isomorphic-unfetch

Webサーバ周り

デプロイ後のURL設計を見越して柔軟なリクエストに耐えれるようにする

Next.jsはファイルシステムにてプロジェクトディレクトリの /pages に配置するJSファイルでURLを決定できますが、
表題のように、CloudFrontなどを利用してプロキシや https://expample.com/hoge 配下にアプリケーションルートを持ってくる時など、Next.js単体では空のディレクトリを生成しなければならかったりとなかなか辛いです。
また、/user/:id のように本来クエリに含める値をURLのパスに対応することができません。
この両者を解決する時に役立つのが、

- next-routes
- koa.js
    - koa-router

です。仕組みとしては以下のようになります。

  1. Koa.jsをWebサーバとして、Next.jsをwebサーバ内で実行するアプリケーションとします
  2. Koa.js - koa-router を使用して、まず外から入ってくるリクエストをKoaのWebサーバで受け取ります (koa-router)
  3. Koa.js で受け取ったリクエストとNext.jsから返したい /pages 配下のファイルをマッピングします (next-routes)
  4. 3.でのマッピングよりリクエストから返したいNext.jsのページが返ります

実装例

/**
 * 今回のroutingの簡潔な流れ
 */

/**
 * web server 側でroutingのmappingを作成
 * @see {https://github.com/fridays/next-routes}
 * @routes/index.js
 */
const nextRoutes = require('next-routes');
const routes = module.exports = nextRoutes();

routes
    // .add( /pages 配下のファイル, マッピングしたいリクエストのパス )
    .add('profileRedist', '/profile/redist')
    .add('profileEdit', '/profile/edit/:id')
    .add('どんどん', '追加していく');


/**
 * web server 側でRequestを受け付ける処理
 * @server.js
 */
const Koa = require('koa');
const next = require('next');

// web server用のroutingモジュールが最終的にエンドユーザからのリクエストを受け付ける
const Router = require('koa-router');
// さっき定義したnext.jsに紐付けたいroutingのmoduleを呼び出す
const routes = require('./routes');

const port = parseInt(process.env.PORT, 10) || 3000;
const dev = process.env.NODE_DEV !== 'production';

// next.jsを呼び出す
const app = next({ dev, quiet: true });
// next.jsをnext-routesでラップする(web server的にroutingのhandlerなので、名前がhandle)
const handle = routes.getRequestHandler(app);

// これはhot reloadの処理をするためにnextがprepare関数でweb serverを監視
app.prepare()
    .then(() => {
        // web server をインスタンス化する
        const server = new Koa();
        // web server のルータをインスタンス化する
        const router = new Router();

        // web serverに来る全てのリクエストをnext.jsをnext-routesでラップしたhandleへ渡す準備
        // ctx.req, ctx.res がnext.js側に渡ってきて値などをゴニョゴニョできる
        router.get('*', async ctx => {
            await handle(ctx.req, ctx.res);
            ctx.response = false;
        });

        // web server側で通信が成功した時の処理の準備
        server.use(async (ctx, next) => {
            ctx.res.statusCode = 200;
            await next();
        });

        // web serverのルーターを起動する
        server.use(router.routes());

        // web server起動
        server.listen(port, err => {
            if(err) throw err;
            console.log(`> Ready on http://localhost:${port}`);
        });
    });

いきなりデプロイ後のことなんて。。。と思うかもしれませんが、アプリケーションの外側の仕組みはアプリケーションを作る前にある程度イメージしないといざデプロイの際に「やっちまった」案件になりかねないので、チームのエンジニアとしっかり議論することをおすすめします。

ちなみに、ログインセッションもKoa.jsで管理したかったので、上記と合わせてKoa.jsを選定しました。

ECSのデプロイはヘルスチェックを忘れずに

ECSデプロイの際は、ALBより各ECSコンテナにヘルスチェックが走ります。
これがないと、最終的なコンテナのビルドが失敗します。

koa-simple-healthcheckを使用しましたが、ヘルスチェックのパスを任意で変換できるのでおすすめです。
また、ヘルスチェックの通信はECSのVPC内で完結する通信になります。

/** こんな感じで使う */
server.use(require('koa-simple-healthcheck')({
    path: '/health',
    healthy: () => {
        return 'ok';
    },
}))

アプリケーション周り

getInitialProps を上手く使ってデータ取得

Nest.jsの機能の特徴の一つとして getInitialProps があります。
各ページの表示タイミング = SSRの初期ロード及びSPAでの画面遷移での表示 で実行されます。
getInitialProps を上手く使用することで、Next.jsのデータ取得・送信のタイミングは

  1. getInitialProps 実行時
  2. Presentationコンポーネントでのユーザ操作

の2つに大きく二分することができます。
よって、 componentDidMount などで fetch などを行う処理を削減できます。

実装例

/** Action で以下のようなものがあったとする */
/* ① */ const GET_ACCOUNT_LIST = 'GET_ACCOUNT_LIST';
/* ② */ const POST_ACCOUNT_REGIST_DATA = 'POST_ACCOUNT_REGIST_DATA';
/* ...etc */

/**
 * ① は仕様上、ページが表示されたら常に情報を取得したい
 * ② は仕様上、エンドユーザがボタンを押した時にデータを転送したい
 * と考えると、
 * ① は ページ表示時に常に発火する getInitilaProps で良さそう => Page コンポーネントで定義
 * ② は Presentation コンポーネントで定義
 * とかんがえられるので以下のような実装になる
 */

/** ① の場合 */
// @pages/accountList.js
import * as React from 'react';
import { compose, setStatic } from 'recompose';
import AccountListContainer from '../countainers/AccountListContainer';

import { END } from 'redux-saga';
import { connect } from 'react-redux';

export default connect()(
    compose(
        setStatic(
            'getInitialProps',
            async (ctx) => {
                const { store, isServer } = ctx;
                store.dispatch({ type: 'GET_ACCOUNT_LIST' });
                store.dispatch(END);
                await store.sagaTask.done;

                if(!isServer) {
                    store.runSagaTask();
                }
            }
        )
    )(() => (
        <AccountListContainer />
    )
));

/** ② の場合 */
// @components/accountRegist.js
import * as React from 'react';
import { compose, pure } from 'recompose';

export default compose(
    pure
)(({ actions }) => (
    <form onSubmit={(event) => { actions.postAccountRegistData(event); event.preventDefault(); }}>

        { /** いろいろと省略 */ }

        <input
            type="submit"
            value="登録"
            />
    </form>
));

コンポーネントはSFC (Stateless Functional Component) で記述

コンポーネントの各レイヤに状態(= State)を持つことはいつ副作用が起こるかわかりません。そこで今回は状態を排除した書き方に寄せました。

/** 従来 */
import { Component } from 'react';
class NoGoodComponent extends Component {
    /** 色々な状態をクラス側で記述できてしまう */
    render() {
        return (<div>No Good ...</div>);
    }
}

/** SFC */
import * as React from 'react';
const GoodComponent = () => (<div>Good !!!</div>);
const GoodComponentWithProps = ({originText}) => (<div>{originText}</div>);

SFCであればコンポーネント内で状態の操作が簡単にできなくなります(どうしてもしなければならないフェーズはあるので、絶対と言うわけではありませんが)。
これにより、基本的にpresentationalなコンポーネントはpage, containerから渡ってきた値をのみを使用してviewの構築をします。
仕事が明確なぶんやることに集中できると考えています。

Recomposeを使って画面のコンポーネントをまるごと使い回す

今回で言いますと、フォーム画面で 新規登録 => 確認 => (登録) => 一覧 => 編集 などのユースケースを考える場合、
登録画面で使っているコンポーネントは使いまわしたいです。

仕様にもよるかと思いますが、おそらく登録 (POST) と編集 (PUT) のAPIが変わるため、submit時のhandler内で呼び出すactionを出し分けないといけないかと思います。
ただ同じようなコンポーネントを2回書いたりするのは面倒なので楽したい。
そこで、RecomposeのwithHandlersを使用します。

実装例

import { compose, withHandlers } from 'recompose';

/** base form [使い回す管理画面のコンポーネント] */
const BaseForm = ({
    onSubmit,
    handleSubmit,
    customSubmit, // <= redux-form の handleSubmit から渡ってくる from データ(values)を自身のhandlerで巻き取る
    actions
}) => (
    <form onSubmit={handleSubmit(customSubmit(actions))}
        // ...
        <button type="submit" />
    </form>
);

/** regist form [新規登録用] */
export compose(
    withHandlers({
        customSubmit: props => actions => values => {
            // POST 用のアクションを呼び出す
            actions.postRegistData(values);
        }
    })
)(BaseForm);

/** edit form [編集用] */
export compose(
    withHandlers({
        customSubmit: props => actions => values => {
            // PUT 用のアクションを呼び出す
            actions.putUpdateData(values);
        }
    })
)(BaseForm); // <= 使うフォームのコンポーネントは同じ

このようにフォーム画面は一切新規登録と編集画面のactionの差異を気にすること無く開発ができます。
ソースコードも節約出来るので煩雑になりやすいコンポーネントファイルの見通しも良くなります。

Redux-formが使える

Redux-formでフォーム画面を作成した体験がとても良かったので突然ですが紹介します。

主に良いところ

  • 制御するフォーム個別にRedux-formが処理するためのStateを割り当てることができる => 自分たちのStateを拡張する必要がない。汚染もない。
  • Event Listener系の処理を全てRedux-form側で処理してくれる => 全てRedux経由の処理なのでReduxにMiddleware挟んでもそこに処理を書ける
  • フォーム要素の拡張を独自で出来る
  • 他のReact UI moduleとの連携も割といける => コンポーネントの階層は深くなるが、一から実装する必要がないので比較的楽。
  • バリデーションの実装例が豊富 => Exampleの内容で一般的なフォームはほぼ作成可能かと。。。

簡単な使い始め方

/**
  * reducer
  * @reducer/index.js 仮に root reducer でマウントする場合
  */
import { combineReducers } from 'redux';
import { reducer as form } from 'redux-form';

export default combineReducers({
    form,
    // other reducers ...
});


/**
  * container
  * @container/FormContainer.js
  */
import Form from 'components/Form';
import { reduxForm } from 'redux-form';
import { connect } from 'react-redux';

const mapStateToProps = state => { /** よしなに 😌 */ };
const mapDispatchToProps = dispatch => { /** よしなに 😌 */ };

/** Form コンポーネントと redux-form をコネクト */
const FormContainer = reduxForm({
    // 自分のつけたいフォーム画面制御用のStateの名前を指定することで、
    // State.form.MyFormState という State が自動的に生成される。
    // これは該当のフォーム画面を使っていない場合 redux-form の action によって破棄される。
    form: 'MyFormState',
})(Form);

/** Provider コンポーネントから渡ってくる store 内の state, dispatch を redux-form と結合した form コンポーネントにコネクト */
export default connect(mapStateToProps, mapDispatchToProps)(
    FormContainer
);

カスタムコンポーネント

Redux-formでフォームの要素を使う場合、 Fieldコンポーネントに自分の使いたいフォームのtypeを継承させて使います。
がこの場合、Styled-jsxでスタイルを当てたり、フォーム要素周りをよしなに拡張する時やりたいことができない場合があります。

こういう場合、Redux-formはFieldコンポーネント内に内包しているフォーム用を拡張できるようになっています。

/** 通常 */
<Field
    name="名前"
    component="input" // <= input tag を使用する
    type="text" // <= type: text に設定する
    placeholder="名前を入力して下さい"
    // ... その他いろいろと追加できます
/>


/** 拡張 */
const CustomInput = ({
    input,
    label,
    placeholder,
    type,
    meta: { touched, error }
}) => (
    <div>
        <label>{label}</label>
        <div>
            <input
                {...input}
                placeholder={placeholder}
                type={type}
            />
            {touched && error && <div>{error}</div>}
        </div>
        // ここにstyled-jsx: <style jsx>{CustomStyle}</style> を入れることもできます
    </div>
);


<Field
    name="名前"
    label="名前(HNでもいいよ)"
    component={CustomInput} // <= 自分の作成したフォーム要素のコンポーネントを割り当てる
    type="text"
    placeholder="名前を入力して下さい"
/>

バリデーション

各Field コンポーネント上で個別のバリデーションが実装できます。

import { Field } from 'redux-form';
import CustomInput from 'CustomInput';


/** validation define */
const required = value => (value ? undefined : ErrorMessages.required);
const maxNumber = max => value =>
    value && value > max ? `${max}以下で入力してください。` : undefined;


const maxNumber20 = maxNumber(20);


/** attach validation */
<Field
    name="名前"
    label="名前(HNでもいいよ)"
    component={CustomInput} // <= 自分の作成したフォーム要素のコンポーネントを割り当てる
    type="text"
    placeholder="名前を入力して下さい"
    validate={[ required, maxNumber20 ]}
/>

Filed コンポーネントの validate props に配列として好きなだけ渡せます。
よって、同じコンポーネントでもバリデーションの変更が効かず、同じコンポーネントだけどバリデーションの違うコンポーネントを作成する必要はありません☺️

Redux-sagaのタスクにビジネスロジックを集中させる

Redux-sagaを使用するにあたってactionやreducerの機能にある程度制限をもたせました。
端的にいうと、ビジネスロジックをRedux-sagaのタスク内で完結できるよう今回は実装した形になります。
詳細は👉こちらの記事にありますので、お時間あるかた是非お読み下さい。

記事の中ではRedux-sagaのタスクをPlantUMLで設計してから実装するなどの試みを行っています。

Redux-saga ✕ Redux-form ✕ Sweetalert でポップアップをラクラク実装・制御

Redux-sagaのタスクはgenerator関数なので async/await に対応しているライブラリと非常に相性が良いです。
今回、バリデーションエラーや確認のためのポップアップの処理にSweetalertを使用したのですが、これが promise を返すため非常に相性が良いです。
またRedux-formのAPIも同様に yield で制御できるため、 フォーム画面からの入力値 => バリデーション => submitの後処理 => ポップアップで入力値の正否を表示 => API送信 => ページ遷移 などの一連の処理が一つのストリーム内で記述できます。

中途半端にポップアップ画面を自作するくらいなら、Sweetalertでサクッと書いてしまったほうが楽です。

一通り設計・実装してみて

Next.jsといっても基本はReactのアプリケーションなので、特段難しいことはないのですが、

  • URLの柔軟性やアプリケーションで管理しないものを管理したい場合、特段なければWebサーバ上にNext.jsのアプリケーションを展開した方がデプロイまでの環境にそれほど差異が出ない
  • アプリケーションの外側もしっかり固める・技術を知る
  • getIinitialProsp などSSR時の挙動も理解しておく

ココらへんがしっかり自分の中でコントロールできると、Next.jsで開発もそこまで慌てることはなさそうです。
今回アプリケーションの乗せたのがAWS-ECSですが、基本はWebのネットワークの知識の応用なのではじめての方も臆せず挑戦してみて下さい (私は今回AWSでの開発が初めてでした)。

アプリケーション周りが固まってしまえばビルドなどは一切気にせず、Reactの開発に集中できるのがNext.jsの良いところかと思っています。
今回はKoa.js周りの次にすぐReactの設計に入ることができたので、SFCやRedux-formなど初めて触る技術も存分にトライすることができました。

Nuxt.jsより先に生まれたのに、Nuxt.jsに先を超されてしまった感が強いNext.jsですが、
管理画面などしっかりアプリケーションを作りたい場合、かなり有用かと思いますのでこちらの記事に少しでもビビっと来た方は是非Next.jsと戯れてみて下さい。

15
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
5