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

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


概要

今時のフロントエンドってどうやって実装すればいいのか、実際に作りながら説明する

ところどころ省略しているものもあるが、業務運用に耐えうる設計を前提に書いたつもりである

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

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


構成

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



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


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


UIサーバー

UIサーバーはフロントエンド、つまりブラウザとのやりとりの役割を持つ

よくある以下のような構成から、ブラウザとのやりとり部分だけ切り出したものである


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


具体的な役割は以下

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


扱う技術・キーワード

  • Express


作るもの

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

// TODO ここにgitのURL


前提

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の正規表現 
// SPAでログイン後のページではルーティングせずに、ログインページだけでルーティングするURLがあればここに追加する 
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 }) => { 
    const data = response.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-wserver 
以下、作られたファイルとこれから作るファイルのディレクトリ構成

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章はここまで

コメント

このブログの人気の投稿

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2021-04-30 23:37:32 RSSフィード2021-04-30 23:00 分まとめ(42件)

投稿時間:2023-02-05 02:09:04 RSSフィード2023-02-05 02:00 分まとめ(9件)