概要
今時のフロントエンドってどうやって実装すればいいのか、実際に作りながら説明する
ところどころ省略しているものもあるが、業務運用に耐えうる設計を前提に書いたつもりである
以下の構成で紹介する。この投稿は第1章
- 第1章 UIサーバー編(今ここ)
- 第2章 フロントエンド編
- 第3章 フロントエンド-redux編 (まだない)
構成
以下のような構成を想定している
ここではUIサーバーとフロントエンドの実装を、ログイン画面の作成を通じて説明する
UIサーバー
UIサーバーはフロントエンド、つまりブラウザとのやりとりの役割を持つ
よくある以下のような構成から、ブラウザとのやりとり部分だけ切り出したものである
具体的な役割は以下
- セッションの管理
- 静的リソースのホスト
- バックエンドとの橋渡し
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.js
でapp.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.js
でlocals.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.json
にscript
を書いておくと便利
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.js
とjs/app.js
を作成する。
これらはそれぞれ、ui/public/js/login.js
とpublic_authenticated/js/app.js
に配置すれば、UIサーバーが静的リソースとしてホストする。
ちなみに、前者はログイン前でもログイン後でもアクセスでき、後者はログイン後でないとアクセスできない。
その設定は、ui/router/index.js
で記述している。
これによって、ログイン前にビジネスロジックの入ったコードを権限のないユーザーに見られるリスクを無くしている。
ui/public/js/login.js
とpublic_authenticated/js/app.js
にそれぞれjsファイルをおき、前者のjsが読み込まれたページに正しいログインフォームを実装すれば完成である
フロントエンド編では、それらのjsファイルを今時の技術を使って作りながら紹介する
以下、フロントエンド編で扱う技術(予定)
- react
- redux
- webpack
- babel
- eslint
- atomic design
- storybook
- less
第1章はここまで