LoginSignup
59
58

More than 3 years have passed since last update.

ExpressサーバーからReactまでのフロントエンドハンズオン 第1章〜UIサーバー編〜

Last updated at Posted at 2018-12-13

概要

今時のフロントエンドってどうやって実装すればいいのか、実際に作りながら説明する
ところどころ省略しているものもあるが、業務運用に耐えうる設計を前提に書いたつもりである

以下の構成で紹介する。この投稿は第1章

  • 第1章 UIサーバー編(今ここ)
  • 第2章 フロントエンド編
  • 第3章 フロントエンド-redux編 (まだない)

構成

以下のような構成を想定している

スクリーンショット 2018-12-10 23.04.57.png

ここではUIサーバーとフロントエンドの実装を、ログイン画面の作成を通じて説明する

UIサーバー

UIサーバーはフロントエンド、つまりブラウザとのやりとりの役割を持つ
よくある以下のような構成から、ブラウザとのやりとり部分だけ切り出したものである
スクリーンショット 2018-12-10 23.04.28.png

具体的な役割は以下

  • セッションの管理
  • 静的リソースのホスト
  • バックエンドとの橋渡し

UIサーバーがセッションの管理や静的リソースのホストを行うことで、バックエンドはJSONでのやりとりを前提としたAPIのみの役割になる

扱う技術・キーワード

  • Express

作るもの

  • ログイン済み状態なら、アプリケーション画面(ログイン後の画面)を返す
  • 未ログイン状態なら、ログイン画面を返す。アプリケーション画面のリソースは取得できない
  • エラー時にはエラーページを返す
  • ログイン状態はセッションで管理する
  • バックエンドのログインAPIを使ってログインする
  • バックエンドのログインAPIはダミーで実装する

完成したものはこちら
https://github.com/yas-tyoukan/frontend-basic-sample

前提

node(8以上推奨)が入っていること

用意

はじめにディレクトリだけ作る

frontend-sample/
 ├ backend/
 └ ui/

expressでUIサーバを作る

expressとは
参考: Node.jsのフレームワーク「Express」とは【初心者向け】

ユーザーがログイン状態かどうかをセッションで管理する
expressサーバは、以下の3つのHTMLを返す

  • ログイン状態であればログイン後のページ
  • ログイン状態でなければログイン前のページ
  • その他エラーがあればエラーページ

SPA(Single Page Application)で作成するので、他のページはない想定

UIサーバに必要なパッケージのインストール


$ cd ../ui
$ npm init
  (対話が始まるので適当でいい)
npm i -S express csurf axios express-session body-parser ejs

expressサーバ(UIサーバ)の実装

主な機能は、以下

  • 状態に応じて以下の3つのページを返す
    • ログイン状態であればログイン後のページ
    • ログイン状態でなければログイン前のページ
    • その他エラーがあればエラーページ
  • ブラウザからのAPIリクエスト(ここではログインのみ)をバックエンドに橋渡しする
  • 静的リソースを返す
    • ログイン状態に関わらずリソース
    • ログイン状態である場合にのみ返すリソース

以下、これまでに作ったファイルと、これから作るファイルのディレクトリ構成

frontend-sample/
 ├ backend/
 └ ui/
   ├ node_modules/         // npm i した時に作られる
   ├ index.js              // 起動設定や共通処理
   ├ package.json          // npm i した時に作られる
   ├ package-lock.json     // npm i した時に作られる
   └ router/
     ├ index.js            // 基本のルーティング設定
     └ api/
       ├ index.js          // APIのルーティング設定
       └ login/
         └ index.js        // ログインAPIのルーティング設定

ui/index.jsの作成

ui/index.jsを以下のように作成する


// frontend-sample/ui/index.js
const http = require('http');
const express = require('express');
const expressSession = require('express-session');
const bodyParser = require('body-parser');
// redis使う場合は以下のようなパッケージを使う。今回は省略
// const connectRedis = require('connect-redis');
// const IORedis = require('ioredis');

// -------- 環境変数のチェック -------- //
// 必要な環境変数が足りてるか、起動時に分かるようにしている
// 今回は省略
// 以下例
//  if(process.env.SESSION_SECRET_KEY) {
//    throw new Error('env SESSION_SECRET_KEY is not set.')
//  }

const app = express();

// -------- X-Powered-Byヘッダの無効化 -------- //
app.disable('x-powered-by');

// -------- サーバーの起動 -------- //
const server = http.Server(app);
// 環境変数でポートを設定などする。今回は省略して固定
const port = 3000;
server.listen(port);

// -------- セッションの設定 -------- //
// redisを使うならその設定が必要
// スケーリングする場合はメモリではダメ
// 開発環境ならメモリ、そうでないならredisのように分けると開発環境でredis不要になる
// 今回は省略してメモリストアを固定で使っている
const sessionStore = new expressSession.MemoryStore();


// ------ cookie,sessionの設定 ------- //
// expressSessionのオプションは以下参照
// https://github.com/expressjs/session
const session = expressSession({
  store: sessionStore,
  secret: 'catIsKawaii', // 環境変数で設定などする。今回は省略して固定値
  resave: false,
  saveUninitialized: false,
  rolling: true,
  proxy: false, // reverse proxy経由などの場合はtrueにする。環境で分けるようにする。今回は省略
  cookie: {
    secure: false, // httpsならtrueにする。環境で分けるなどする。今回は省略
    httpOnly: true,
    rolling: true,
    maxAge: 3600000, // ミリ秒で指定。環境変数で設定するべきだが、今回は省略
  },
});

app.use(session);

// ------ テンプレートエンジンの設定 ------ //
// ejsを使う
app.set('views', 'views/pages');
app.set('view engine', 'ejs');

// ------ bodyParserの設定 ------- //
// bodyParserの設定は以下参照
// https://github.com/expressjs/body-parser
// クエリパラメータのパースの設定
app.use(bodyParser.urlencoded({
  // extended: trueにするとオブジェクトのネストや配列を保持したまま受け取れる。"foo[bar]=baz"->{foo:{bar:'baz'}}
  extended: true,
  limit: '10mb',
}));

// リクエストボディの容量制限
app.use(bodyParser.json({
  limit: '10mb',
}));

// ------ ルーティング ------ //
app.use('/', require('./router'));

// -------------------------------------------------
//  以下、何のルーティングにもマッチしないorエラー
// -------------------------------------------------

// いずれのルーティングにもマッチしない(==NOT FOUND)
app.use((req, res) => {
  res.status(404);
  res.render('error', {
    param: {
      status: 404,
      url: req.url,
      message: 'not found',
    },
  });
});

// エラーハンドリング
// 引数が4つの関数を設定すると、エラーハンドラ扱いになる
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
  // 想定されるerrの内容によって場合分けなど
  if (err.code === 'EBADCSRFTOKEN') {
    // CSRFTokenのエラー
    res.status(403);
    res.json(err);
    return;
  }
  if (req.method !== 'GET' || /\/api\/.*/.test(req.url)) {
    // GET以外のエラー、または、'/api/*'へのアクセスならエラーオブジェクトを返す
    res.status(500 || err.status);
    res.json(err);
    return;
  }
  // エラーページを返す
  res.render('error');
});

module.exports = app;

ui/router/**/* にルーティング設定の作成

ルーティングに関する設定は、別ファイルに切り出している。
以下のように、ui/router/index.jsを作成し、ルーティング設定を記述する

// ui/router/index.js
const express = require('express');
const csurf = require('csurf');

const router = express.Router();

// ------ ルーティングのログ出力など共通処理 ------ //
router.all('/*', (req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});

// ------- CSRF対策のミドルウェア設定 ------- //
router.use(csurf({
  cookie: false,
}));

// csrfTokenを格納
router.use((req, res, next) => {
  // CSRF対策トークンを入れる
  const locals = res.locals;
  locals.csrfToken = req.csrfToken();
  return next();
});

// -------- 認証チェックが不要なルーティング設定 ここから -------- //
// csrfToken単体で取得
router.get('/csrf-token', (req, res) => {
  res.json({ token: res.locals.csrfToken });
});

// 静的ファイルのルーティング
router.use(express.static('public'));

// ログイン
// eslint-disable-next-line no-unused-vars
router.use('/login', (req, res, next) => {
  // ログインページを返す
  res.render('login');
});

// ログアウト
router.get('/logout', (req, res, next) => {
  // 未ログインの場合は何もせずに/loginにリダイレクト
  if (!req.session.user) {
    res.redirect('/login');
    return;
  }
  // ログイン済みの場合はセッションを破棄してから/loginにリダイレクト
  req.session.destroy((err) => {
    if (err) {
      next(err);
      return;
    }
    res.redirect('/login');
  });
});

// ログインページに飛ばすURLの正規表現
// ログイン後のページではルーティングせずに、ログインページだけでルーティングするURLがあればここに追加する
// /login/*以下と、/logoutはログインページでルーティングする
const urlsRoutedLoginPage = /^\/(login(\/.*)?|logout)$/;

// ログイン前にアクセス可能なAPI
// パスワードリセットAPIへのアクセスなど、login前でも使用するAPIがあればここに追加する
const apisAccessibleWithoutLogin = /^\/api\/login$/;

// ログイン画面
router.get(urlsRoutedLoginPage, (req, res) => {
  res.render('login');
});

// -------- 認証チェックが不要なルーティング設定 ここまで -------- //

// ------------------ 認証チェック ------------------ //
router.use((req, res, next) => {
  if (apisAccessibleWithoutLogin.test(req.url)) {
    // ログイン不要でアクセスできるAPIへのアクセスは認証チェックしない
    next();
    return;
  }
  // ログイン済みかどうかチェック
  const { session } = req;
  const authenticated = session && session.authenticated;
  if (authenticated) {
    // ログイン済みならOK
    next();
    return;
  }
  // ----- 以下は未ログインの場合 ----- //
  // GET以外のアクセス及びAPIアクセスの禁止
  if (req.method !== 'GET' || /\/api\/.*/.test(req.url)) {
    // 401を返して終了
    // ui/index.jsのエラーハンドリングで処理される
    next({ status: 401 });
    return;
  }
  // APIアクセスでないGETアクセスは、すべてログインページを返す
  res.redirect('/login');
});

// -------- 認証チェックが必要なルーティング設定 -------- //

// 静的ファイルのルーティング
router.use(express.static('public_authenticated'));

// APIのルーティング
router.use('/api', require('./api'));

// ログイン後のページのルーティング
router.get('/*', (req, res) => {
  res.header('Content-Type', 'text/html');
  res.render('app');
});

module.exports = router;

res.render('login'); としているところは、ui/index.jsapp.set('views', 'views/pages'); としたことにより、views/pages/login.ejsの内容を返すようになる。 ejsファイルの作成は後にして、次にAPIへのルーティングを実装する

APIへのルーティングに関する設定は、別ファイルに切り出している
今回はログインAPIを作成する
POST: /api/loginでリクエストを送る想定
以下のファイルを作成して、ログインAPIのルーティングと実装を行う

  • ui/router/api/index.js
  • ui/router/api/login/index.js
// ui/router/api/index.js
const router = require('express').Router();

// ------ APIのルーティング ここから ------ //
router.use('/login', require('./login'));
// ------ APIのルーティング ここまで ------ //

router.all('/*', (req, res) => {
  // APIのルーティングにマッチしなかったものは404をJSONで返す
  res.status(404).json({ url: req.url, message: 'not found' });
});

module.exports = router;

APIが増えたらloginと同様に、// ------ APIのルーティング ここから ------ //の部分に追加する
すでにrouter/index.jsで、loginのAPIへのアクセスはログイン前に可能で、それ以外のAPIへのアクセスはログイン前にはできないように設定している

次に、ログインAPIのルーティングと実装をする

// ui/router/api/login/index.js
const router = require('express').Router();
const axios = require('axios');

router.route('/').post((req, res, next) => {
  const { id, password } = req.body;
  if (!id || !password) {
    // idまたはpasswordがない場合は、バックエンドに送らずにエラーとして処理
    res.status(401).json({ message: 'failed' });
    return;
  }

  // 環境変数を元にURLを生成する。今回は省略して固定
  const url = 'localhost:3001/login';

  axios.post(url, { id, password }).then(({ body }) => {
    if (!body.user) {
      // userが渡されなかったらログイン失敗とみなす
      res.status(401).json({ message: 'failed' });
      return;
    }
    // 取得したデータを元にセッションを再生成
    req.session.regenerate((error) => {
      if (error) {
        // セッション再生成に失敗したらエラー
        next(error);
        return;
      }
      // セッションに必要な情報を格納
      const { session } = req;
      const { user } = body;
      session.user = user;
      res.json({ user });
    });
  }).catch(next);
});

module.exports = router;

ページの作成

ejsを使う
参考 Expressにおけるejsの使い方

以下、3つのページを作成する

  • 未ログイン時のページ
  • ログイン済の時のページ
  • エラーページ

共通部分は部分ファイルに抜き出している(コード中のinclude()している部分)

以下、現時点で作成したディレクトリと、これから作るファイルのディレクトリ構成

frontend-sample/
 ├ backend/
 └ ui/
   ├ node_modules/
   ├ router/
   ├ package.json
   ├ package-lock.json
   ├ index.js
   └ views/
     ├ pages/
       ├ login.ejs
       ├ app.ejs
       └ error.js
     └ partials/
       └ head.ejs   // APIのルーティング設定    

各ページ(ejs)の実装

<%# ui/views/pages/login.ejs %>
<!DOCTYPE html>
<html lang="ja">
<%- include('../partials/head', { title: 'ログイン' }) %>
<body>
  <div id="root"></div>
  <script src="/js/login.js"></script>
</body>
</html>
<%# ui/views/pages/app.ejs %>
<!DOCTYPE html>
<html lang="ja">
<%- include('../partials/head', { title: 'ログインした人だけ見られるページ' }) %>
<body>
  <div id="root"></div>
  <script src="/js/app.js"></script>
</body>
</html>
<%# ui/views/pages/error.ejs %>
<!DOCTYPE html>
<html lang="ja">
<head>
<title>ページが表示できません</title>
</head>
<body>
  <p>ページが表示できません</p>
</body>
</html>

エラーページのデザインについては省略している。必要ならstyleやscriptなどを追加する。

各ページでincludeしている部分ファイル

<%# ui/views/partials/head.ejs %>
<head>
  <meta charset="utf8">
  <meta name="csrf-token" content="<%= csrfToken %>">
  <meta
    name="viewport"
    content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
  />
  <title><%= title %></title>
</head>

ejsはlocalsのプロパティにアクセスできるので、ui/router/index.jslocals.csrfToken = req.csrfToken();と書いたことにより、csrf-tokenを埋め込める

UIサーバーはここまでで完成

動作確認

UIサーバーを立ち上げる

$ cd ui/
$ node index.js

エラーが出て立ち上がらなかったら、何かが(あるいはこの投稿が)おかしいのでエラーメッセージを元に修正すること

別ウィンドウで以下で確認

ログインページの取得

$ curl localhost:3000/login
(login.ejsの内容が帰ってくる)

ログインAPIへのPOST(CSRFTokenのエラー)

$  curl -X POST -H "Content-Type: application/json" -d '{"id":"user1", "password":"p"}' localhost:3000/api/login
{"code":"EBADCSRFTOKEN"}

ダミーのバックエンドのログインAPIを作る

ログインAPIがアクセスするバックエンドのエンドポイントをダミーで実装する

json-serverを使う
json-serverは簡単にモックのAPIを作れるので便利

$ cd backend
$ npm init
  (対話が始まるので適当でいい)
$ npm i json-server

以下、作られたファイルとこれから作るファイルのディレクトリ構成

frontend-sample/
 ├ ui/
 ├ backend/
   ├ node_modules/
   ├ package.json
   ├ package-lock.json
   ├ db.json             // json-serverの立ち上げに必要(ここでは空ファイル)
   └ login-mock.json     // ログインAPIのモック実装

ログインAPIのモックを作成する

POST: /login して、 {username:'user1', password:'p'} なら200, それ以外なら401を返すだけのものにした。

// backend/login-mock.js
module.exports = (req, res, next) => {
  if (req.method === 'POST' && req.path === '/login') {
    const { id, password } = req.body;
    if (id === 'user1' && password === 'p') {
      res.status(200).json({ user: { id } });
    } else {
      res.status(401).json({ message: 'failed' });
    }
    return;
  }
  next();
};

空でいいのでdb.jsonを作る

$ touch db.json

json-serverを立ち上げる
UIサーバと被らないようにポート3001にしている

$ npx json-server  db.json -m login-mock.js -p 3001

以下のように表示されればOK

  \{^_^}/ hi!

  Loading db.json
  Loading login-mock.js
  Done

  Resources

  Home
  http://localhost:3001

  Type s + enter at any time to create a snapshot of the database

動作確認

別ターミナルウィンドウで

$ curl -X POST -H "Content-Type: application/json" -d '{"id":"user1", "password":"p"}' localhost:3001/login
{
  "user": {
    "id": "user1"
  }
}

これでバックエンドのモックはOK

CSRFTokenを設定したログインAPIや、ログイン後の画面はフロントの実装をしてから確認する

package.jsonにscriptを書く

サーバーの立ち上げなど、楽にするために、package.jsonscriptを書いておくと便利

backend/package.json

{
  "name": "backend",
  "version": "0.0.0",
  "private": true,
  "description": "mock backend api",
  "dependencies": {
    "json-server": "^0.14.0"
  },
  "scripts": {
    "start": "json-server db.json -m login-mock.js -p 3001"
  }
}

以下で、モックサーバーが立ち上がるようになる

$ cd backend
$ npm run start

ui/package.json

{
  "name": "ui",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.18.0",
    "body-parser": "^1.18.3",
    "csurf": "^1.9.0",
    "ejs": "^2.6.1",
    "express": "^4.16.4",
    "express-session": "^1.15.6"
  },
  "scripts": {
    "start": "node index.js"
  }
}

以下で、UIサーバーが立ち上がるようになる

$ cd ui
$ npm run start

次やること

次に、UIサーバーが返すHTMLファイルに書かれている(=ejsファイルに書いた)、js/login.jsjs/app.jsを作成する。
これらはそれぞれ、ui/public/js/login.jspublic_authenticated/js/app.jsに配置すれば、UIサーバーが静的リソースとしてホストする。

ちなみに、前者はログイン前でもログイン後でもアクセスでき、後者はログイン後でないとアクセスできない。
その設定は、ui/router/index.jsで記述している。
これによって、ログイン前にビジネスロジックの入ったコードを権限のないユーザーに見られるリスクを無くしている。

ui/public/js/login.jspublic_authenticated/js/app.jsにそれぞれjsファイルをおき、前者のjsが読み込まれたページに正しいログインフォームを実装すれば完成である
フロントエンド編では、それらのjsファイルを今時の技術を使って作りながら紹介する

以下、フロントエンド編で扱う技術(予定)

  • react
  • redux
  • webpack
  • babel
  • eslint
  • atomic design
  • storybook
  • less

第1章はここまで

59
58
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
59
58