JavaScriptだけでWebサイト開発

node.js + Express + mongodb系 + React でサービスを作るメモ

Express+mongoose+passportでユーザー登録認証&セッション全部やる

使用するミドルウェア

これらを使用するとロジックをコーディングする必要がなくなる

  • mongoose
  • express-session
  • passport
  • passport-local
  • passport-local-mongoose
  • connect-mongo

setup

$ cd ~/
$ express userlogin
$ cd userlogin
$ npm install
$ npm install express-session passport passport-local connect-mongo mongoose passport-local-mongoose

app.js

以下の内容を追加 * sessionをmongoose経由でmongodbで保存 * /api へのアクセスは認証を通す

app.js

var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var mongoose = require('mongoose');  

app.js

mongoose.Promise = global.Promise;
mongoose.connect("mongodb://localhost/dbname", {useMongoClient: true}); // 接続
// session store
var MongoStore = require('connect-mongo')(session);
app.use(session({
    secret: config.secret.session,
    saveUninitialized: true,
    resave: true,
    store: new MongoStore({mongooseConnection: mongoose.connection})
}));
app.use(passport.initialize());
app.use(passport.session());

var User = require("./models/user");
passport.use(new LocalStrategy(User.authenticate()));
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());

// view にセッション情報を渡す場合はこれ
app.use(function(req,res,next){
    res.locals = {
        user: req.user
    };
    next();
});

// ユーザー登録&ログイン画面
var user = require('./routes/users');

app.use('/users',user);
:
:

データベース定義

models/user.js

var mongoose      = require('mongoose');
var Schema        = mongoose.Schema;
var passportLocalMongoose = require('passport-local-mongoose');

var userSchema = new Schema ({
  username  : String,
  password  : String,
  active    : Boolean
});

userSchema.plugin(passportLocalMongoose);

module.exports = mongoose.model('User', userSchema);

ユーザーコントローラー

routes/users.js

// 登録フォーム
router.get('/signup', function(req, res, next){
    res.render('user/signup', {
        title : 'User Sign up',
        user : {
            username : '',
            password : ''
        }
    });
});
// ログインフォーム
router.get('/signin', function(req, res, next){
    res.render('user/signin', {
        title : 'User Sign in',
        user : {
            username : '',
            password : ''
        }
    });
});
var User = require('../models/user');

// 登録実行
router.post('/signup', function(req, res, next){
    User.register(
        new User({ username: req.body.username, password: req.body.password }),
        req.body.password,
        function(err, user){
            if (err) {
                return res.render('user/signup', {title: 'User Sign up Error', user: req.body, error: err});
            }
            var authenticate = User.authenticate();

            authenticate(req.body.username, req.body.password, function(err, result) {
                if(err) {
                    return res.render('user/signup', {title: 'User Sign up Error', user: req.body, error: err});
                }
                res.render('user/index',{title:'User Signed in', result: result});
            });
        }
    );
});
// ログイン実行
router.post('/signin', passport.authenticate('local'), function(req, res) {
  res.redirect('/');
});

動作確認

  • /signup 登録フォーム username, password をサブミット。成功するとusersコレクションに登録され、sessionにUser情報が入る
  • /signin ログインフォーム 成功するとsessionに該当のUser情報が入る
  • /mypage でユーザー認証, セッションの情報を取得できる。認証が不正な場合は status 401でレスポンスする。
  • req.user に情報が入る(今回は全viewにセッション情報を渡すためres.localsに設定)

create-react-appでreact-router

環境設定

routerフォルダでプロジェクトを作成

$ cd ~/
$ create-react-app router

react-routerインストール

今回はwebサイト

$ cd ~/router
$ npm install --save react-router-dom

src/App.js 編集

  • 前ページ共通で表示するHeader,Footer
  • トップページは/
  • /hoge, /moge でアクセスできるようにする

src/App.js

import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Link } from 'react-router-dom';

const style = {
  common : {
    margin: 0,
    padding: "20px",
    textAlign: "center",
    width: "100%",
    height: "60px",
    backgroundColor: "#000",
    color: "#EEE"
  },
  main : {
    fontSize: "1rem",
    padding: "40px"
  }
} ;
const Header = () => (<header style={style.common}>Common Header</header>);
const Footer = () => (<footer style={style.common}>Common Footer</footer>);
const TopPage = () => (<h1>Top Page</h1>);
const HogePage = () => (<h1>Hoge Page</h1>);
const MogePage = () => (<h1>Moge Page</h1>);
const NoMatch = () => (<h1>Not found!! </h1>);

class App extends Component {
  render() {
    return (
      <Router>
      <div>
        <Header />
        <nav>
          <ul>
          <li><Link to={process.env.PUBLIC_URL + "/"}>top</Link></li>
          <li><Link to={process.env.PUBLIC_URL + "/hoge"}>hoge</Link></li>
          <li><Link to={process.env.PUBLIC_URL + "/moge"}>moge</Link></li>
          </ul>
        </nav>
        <div style={style.main}>
          <Route exact path={process.env.PUBLIC_URL + "/" } component={TopPage} />
          <Route path={process.env.PUBLIC_URL + "/hoge"}  component={HogePage} />
          <Route path={process.env.PUBLIC_URL + "/moge"}  component={MogePage} />
          <Route component={NoMatch}/>
        </div>
        <Footer />
      </div>
      </Router>
    );
  }
}

export default App;

ここで

         <Route exact path={process.env.PUBLIC_URL + "/" } component={TopPage} />
  • exact はその後の/hoge/mogeにマッチしないようにつける。
  • process.env.PUBLIC_URLは 本番配置するベースのURLをpackage.jsonのhomepage設定を反映させるため

起動&確認

$ npm start

localhost:3000 f:id:ichi-bit:20171129161841p:plain

localhost:3000/moge f:id:ichi-bit:20171129161857p:plain

Not Found の設定

上のApp.jsを以下のように変更&追加

src/Appjs

import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom';
const NoMatch = () => (<h1>Not found!! </h1>);
        <Switch style={style.main}>
          <Route exact path={process.env.PUBLIC_URL + "/" } component={TopPage} />
          <Route path={process.env.PUBLIC_URL + "/hoge"}  component={HogePage} />
          <Route path={process.env.PUBLIC_URL + "/moge"}  component={MogePage} />
          <Route component={NoMatch}/>
        </Switch>

ブラウザで確認

f:id:ichi-bit:20171129161918p:plain

node.js+React CMS開発-ソフトウェア構成

CMS開発の続きです

前提条件と目的および採用基準

今回のCMS開発の考え方として

  • ビジネス向けのwebアプリケーション開発を想定
  • メディアサイトのwebアプリケーションを想定
  • 構造化が容易なアーキテクチャー
  • ある程度枯れている&開発者が多い
  • 手軽に作業ができる&コスト最小
  • デザインが自由にできる
  • IaaSに載せやすい *管理サイトおよびメディアサイトの両方を想定
  • Javascriptだけで開発

Reactは決めの問題

  • Vueもいい
  • Angularもいい
  • JQueryも慣れているしプラグインが豊富
  • サービスのNativeアプリは作りたいかも

などなどありますが比較的目にする頻度が高く学習要素の数が少ないReactを採用することとして話をすすめます。 フロントエンドは create-react-app による開発環境が人気があります

管理画面などSPAのフロントエンドは create-react-appによる開発 create-react-app でbuildしたファイルをExpressのstaticファイルとして配置して配信、ExpressサーバーはそのままアプリのAPIサーバーも兼ねてSPA的な動作も実現する。

Node.js + Express

  • Javascriptサーバーのデファクトスタンダード
  • パッケージが充実してきている
  • Expressのmiddlewareがweb開発で必要なものが揃っている

Expressに必要なデファクトスタンダードなmiddlewareを採用する開発 middlewareは別途まとめたい

Database

アプリケーションの内容により適宜選択,express側のmiddlewareやパッケージで吸収できる * MongoDB * MySQL * Postgres * Redis * その他

CMSの記事保存はMongoDB、計数管理などはMySQLに AWSなどで標準的に提供されている運用安定化したもの,携わる技術者の多いものがベスト

構成図

f:id:ichi-bit:20171121141012p:plain

create-react-appのアプリをExpress内で配置、配信する

クライアントReactアプリとWebサーバー&APIサーバーを分けるのが面倒で一箇所(プロセス)でホスティングしたいときがあります

create-react-app で作成したプロジェクトをexpressの /_admin 以下で動作(配信)させてみます

Express環境

$ express together
   create : together
   create : together/package.json
   create : together/app.js
   create : together/public
   create : together/routes
   create : together/routes/index.js
   create : together/routes/users.js
   create : together/views
   create : together/views/index.jade
   create : together/views/layout.jade
   create : together/views/error.jade
   create : together/bin
   create : together/bin/www
   create : together/public/javascripts
   create : together/public/images
   create : together/public/stylesheets
   create : together/public/stylesheets/style.css

   install dependencies:
     $ cd together && npm install

   run the app:
     $ DEBUG=together:* npm start

$ cd together/
$ npm install

create-react-appをexpressプロジェクト内で作成

$ cd together
$ create-react-app client
create-react-app client

Creating a new React app in ~/together/client.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts...

ディレクトリ構造は

together
├── app.js
├── bin
├── client         <-- ここにcreate-react-app
├── node_modules
├── package-lock.json
├── package.json
├── public
├── routes
└── views

express の app.js

app.js

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var index = require('./routes/index');
var users = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());

// 静的ファイルの配信をURL毎に設定
//----------------------------------------------
app.use('/_admin',express.static(path.join(__dirname, 'client/build')));

// publicのstatic
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

clientのreactアプリを express上の /_admin で動作させたいので

app.use('/_admin',express.static(path.join(__dirname, 'client/build')));

create-react-app の package.json

client/package.json

{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "homepage": "/_admin", // <--- ここにBase URLを設定
  "dependencies": {
    "react": "^16.1.1",
    "react-dom": "^16.1.1",
    "react-scripts": "1.0.17"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

create-react-appをbuild

$ cd ~/together/client
$ npm run build

express サーバー起動

$ cd ~/together
$ npm start

ブラウザで確認

localhost:3000 expressのデフォルトページ(route / )が表示されます

localhost:3000/users expressのroute /users が表示されます

localhost:3000/_admin create-react-app のおなじみのデフォルトページが表示されます。

以上です

create-react-app で Material-UIの画面を表示してみる

create-react-appでプロジェクトを作成し、Material-UIを利用した画面を表示します

環境設定

ローカルの開発環境はこちら * macOS High Sierra(v10.13)でJavaScript開発環境を整える

create-react-app でプロジェクトの雛形作成して起動してみる

$ cd ~/
$ create-react-app material
$ cd material
$ npm start
Compiled successfully!

You can now view material in the browser.

  Local:            http://localhost:3000/
  On Your Network:  http://192.168.11.15:3000/

Note that the development build is not optimized.
To create a production build, use npm run build.

ブラウザで確認

f:id:ichi-bit:20171121174304p:plain

material-ui インストール

$ cd ~/material
$ npm install --save material-ui
+ material-ui@0.19.4
added 18 packages in 9.907s

App.js

material-ui の機能を以下のように実装

  • maretial-ui の使用するコンポーネントをrequireする
  • Appbarのメニューアイコンクリックでメニューを表示
  • Reactロゴ画像とwelcome文言をAvatarとテキストのListItemで表示
  • To Get started... 文言を Chipで表示
import React, { Component } from 'react';
import logo from './logo.svg';
// import './App.css';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import AppBar from 'material-ui/AppBar';
import Avatar from 'material-ui/Avatar';
import List from 'material-ui/List/List';
import ListItem from 'material-ui/List/ListItem';
import Menu from 'material-ui/Menu';
import MenuItem from 'material-ui/MenuItem';
import Popover, {PopoverAnimationVertical} from 'material-ui/Popover';
import Chip from 'material-ui/Chip';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      open: false
    };
  }

  handleToggle = (event) => {
    this.setState({
      open: !this.state.open,
      anchorEl: event.currentTarget,
    });
  }

  handleRequestClose = () => {
    this.setState({
      open: false,
    });
  };

  render() {
    return (
      <div className="App">
        <MuiThemeProvider>
          <div>
            <AppBar title="Title" iconClassNameRight="muidocs-icon-navigation-expand-more" showMenuIconButton={true} onLeftIconButtonTouchTap={this.handleToggle} />
            <Popover
              open={this.state.open}
              anchorEl={this.state.anchorEl}
              anchorOrigin={{horizontal: 'left', vertical: 'bottom'}}
              targetOrigin={{horizontal: 'left', vertical: 'top'}}
              onRequestClose={this.handleRequestClose}
              animation={PopoverAnimationVertical}
            >
              <Menu>
                <MenuItem primaryText="Refresh" />
                <MenuItem primaryText="Help &amp; feedback" />
                <MenuItem primaryText="Settings" />
                <MenuItem primaryText="Sign out" />
              </Menu>
            </Popover>
          </div>
          <header>
            <List>
              <ListItem leftAvatar={<Avatar src={logo} />}>
                Welcome to React
              </ListItem>
            </List>
          </header>

          <Chip>
            To Get started, edit <code>src/App.js</code> and save to reload.
          </Chip>

        </MuiThemeProvider>
      </div>
    );
  }
}

export default App;

起動

$ cd ~/material
$ npm start

ブラウザで確認

localhost:3000

f:id:ichi-bit:20171121174229p:plain

メニューアイコンクリックで f:id:ichi-bit:20171121174245p:plain できました

express-react-views+Material-UIをSSRで試す

わざわざServer Side Renderingでやるべきことではないですが、やってみました。

やったこと

  • express-generator で雛形を作る
  • app.jsで view engineに express-react-views を設定
  • view 内の layout.jsxとindex.jsx でmaterial-uiを利用
  • ブラウザで確認

環境構築

$ express material
$ cd material
$ npm install
$ npm install --save express-react-views react react-dom material-ui

app.js

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var index = require('./routes/index');
var users = require('./routes/users');

var app = express();

// express-react-views engine setup
// ---------------------------------------------------------------
app.set('views', __dirname + '/views');
app.set('view engine', 'jsx');
app.engine('jsx', require('express-react-views').createEngine());


// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

views/layout.jsx

var React = require('react');
const {getMuiTheme, MuiThemeProvider} = require('material-ui/styles');

class Layout extends React.Component {
  render() {
      console.log(this.props);
    return (
      <html>
        <head><title>{this.props.title}</title></head>
        <body>
            <MuiThemeProvider muiTheme={getMuiTheme()}>
                {this.props.children}
            </MuiThemeProvider>
        </body>
      </html>
    );
  }
}

module.exports = Layout;

views/index.jsx

var React = require('react');
var Layout = require('./layout');
var {AppBar, RaisedButton, Drawer, MenuItem} = require('material-ui');

class Index extends React.Component {
    constructor(props) {
      super(props);
      this.state = {open: false};
    }

    handleToggle() {
        this.setState({open: !this.state.open});
    }
    
    render() {
        return (
            <Layout title={this.props.title}>
                <AppBar title={this.props.title} iconClassNameRight="muidocs-icon-navigation-expand-more"/>
                    This index page
                <RaisedButton label="Toggle Drawer" onClick={this.handleToggle} />
                <Drawer open={this.state.open}>
                    <MenuItem>Menu Item</MenuItem>
                    <MenuItem>Menu Item 2</MenuItem>
                </Drawer>
            </Layout>
        );
    }
}

module.exports = Index;

サーバー起動

$ npm start

ブラウザで確認

localhost:3000

f:id:ichi-bit:20171121123253p:plain

イベント処理が動作すべきところは反映していません。見た目だけ反映しています。 HTMLは非常に汚いです。

まとめ

  • 見た目だけ反映したい場合は有効
  • クライアントサイドでイベント処理が必要
  • express-react-viewsでは厳しい
  • 詳しくはこちら github.com

  • こんなのも github.com

Express+mongooseでsession管理

mongooseでセッション管理

パッケージインストール

$ npm install --save mongoose
$ npm install --save express-session
$ npm install --save connect-mongo

プロジェクト雛形作成

$ express session

app.js

var express = require('express');
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var index = require('./routes/index');
var users = require('./routes/users');

var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

var mongoose = require('mongoose');
const option = {
  useMongoClient: true
};
mongoose.connect("mongodb://localhost/session",option);
app.use(session({
  secret: 'somthing secret',
  saveUninitialized: true,
  resave: true,
  store: new MongoStore({mongooseConnection: mongoose.connection})
}));

app.use('/', index);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

routers/users.js

/users へのアクセスでアクセス数をカウントする。

var express = require('express');
var router = express.Router();

/* GET users listing. */
router.get('/', function(req, res, next) {
  req.session.views++;
  console.log(req.session);
  res.send('respond with a resource');
});

module.exports = router;

ブラウザでアクセス

console

$ npm start
> session@0.0.0 start ~/session
> node ./bin/www

localhost:3000/users

sessionの viewsパラメータに書き込んだカウント数がアクセスごとに上がっていく

Session {
 cookie:
 { path: '/',
 _expires: null,
 originalMaxAge: null,
 httpOnly: true},
 views: 20 }

GET /users 304 12.501 ms - -

mongodbの中身確認

views に 数字が保存されているのを確認

$ mongo
MongoDB shell version v3.4.10
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.9
> use session
switched to db session
> db.sessions.find()
{ "_id" : "1V04JMJx6rsIcgAtl1LJ0ovxaho_H1aB", "session" : "{\"cookie\":{\"originalMaxAge\":null,\"expires\":null,\"httpOnly\":true,\"path\":\"/\"},\"views\":20}", "expires" : ISODate("2017-12-04T04:12:29.189Z") }
> 

node.js+React CMS開発-モデル定義

CMS開発シリーズの続きです

今回は前回作成した雛形にmongooseスキーマ&モデルの定義を実装します。

目次

mongooseのインストール

$ cd ~/nexpress
$ npm install --save mongoose

auto increment を行うmongoose-sequenceを導入

MySQLなどのRDBで使われる自動採番(sequence) を利用したいのでmongoose-sequenceをインストール

$ npm install --save mongoose-sequence

ユーザーパスワードの暗号化のためmongoose-bycryptを導入

パスワードを暗号化して保存するためフィールドの暗号化と照合ができるプラグインmongoose-bycryptをインストール

$ npm install --save mongoose-bycrypt

モデル定義をするディレクトリ構造を決める

nexpress
├── README.md
├── app.js
├── bin
├── config.js     <----- アプリケーション共通の設定情報
├── db.js         <----- mongodb接続や,mongoose,puluginに関する共通設定
├── models/       <----- ここにモデルごとにファイルで配置する
├── node_modules/
├── package-lock.json
├── package.json
├── public/       <----- 静的ファイル
├── routes/       <----- ルーティング
└── views/        <----- ビュー

共通module

config.js

アプリケーション全体を通して使用するパラメータなどを定義します。

// config.js
const config = {
 app: {
   port: 3000
 },
 db: {
   host: 'localhost',
   port: 27017,
   name: 'nexpress'
 },
 secret: {
   key:  '#%$SomethingRandomString&%$'
 }
};

module.exports = config;

db.js

  • データベースへの接続
  • mongoose プラグインの追加
var config = require('./config');
var mongoose = require('mongoose');  // mongoose 利用
const AutoIncrement = require('mongoose-sequence')(mongoose); // 自動採番プラグイン

const { db: { host, port, name } } = config;

const option = {
  useMongoClient: true,
  poolSize: 10
};
mongoose.Promise = global.Promise;
mongoose.connect(`mongodb:\/\/${host}:${port}/${name}`, option); // 接続
mongoose.AutoIncrement = AutoIncrement; 

module.exports = mongoose;

モデル定義

node.js+React CMS を設計する(データベース仕様))で定義したスキーマをnexpress/modelsディレクトリに定義する

user.js

ユーザー情報

var mongoose      = require('../db'); // 接続とsequenceの初期化が行われている
var Schema        = mongoose.Schema;

// 定義
var userSchema = new Schema ({
  user_id   : Number,
  login     : String,
  password  : String
});

// user_idは自動採番
userSchema.plugin(mongoose.AutoIncrement, { inc_field: 'user_id'});
// passwordフィールドを自動暗号化
userSchema.plugin(require('mongoose-bcrypt'),{ fields: ['password'] });

module.exports = mongoose.model('User', userSchema);

site.js

サイト情報

var mongoose      = require('../db');
var Schema        = mongoose.Schema;

var siteSchema = new Schema ({
  site_id: Number,
  name   : String,
  title  : String
});

siteSchema.plugin(mongoose.AutoIncrement, { inc_field: 'site_id' });

module.exports = mongoose.model('Site', siteSchema);

user-site.js

サイトの所有者、利用者、共同編集者ユーザーの登録情報。 ユーザーとサイトのリレーション

var mongoose      = require('../db');
var Schema        = mongoose.Schema

var userSiteSchema = new Schema ({
  user_id   : { type: Number, refs: 'User' },
  site_id   : { type: Number, refs: 'Site' },
  role      : String
});

module.exports = mongoose.model('UserSite', userSiteSchema);

category.js

カテゴリ情報

var mongoose      = require('../db');
var Schema        = mongoose.Schema;

var categorySchema = new Schema ({
  site_id: { type: Number, refs: Site },
  category_id: Number
  name   : String,
  title  : String
});

categorySchema.plugin(mongoose.AutoIncrement, { inc_field: 'category_id'});

module.exports = mongoose.model('Category', categorySchema);

category-category.js

カテゴリ同士の親子関係 カテゴリ間リレーション定義

var mongoose      = require('../db');
var Schema        = mongoose.Schema

var contentCategorySchema = new Schema ({
  category_id  : { type: Number, refs: 'Category' },
  content_id   : { type: Number, refs: 'Content' }
});

module.exports = mongoose.model('ContentCategory', contentCategorySchema);

content.js

コンテンツ、記事情報

var mongoose      = require('../db');
var Schema        = mongoose.Schema;

var contentSchema = new Schema ({
  site_id: { type: Number, refs: Site },
  content_id: Number,
  user_id: { type: Number, refs: User},
  title   : String,
  summary  : String,
  image: String,
  publish_date: { type: Date, default: Date.now },
  expire_date: { type: Date, default: null },
  article: [Schema.Types.Mixed]
});

contentSchema.plugin(mongoose.AutoIncrement, { inc_field: 'content_id'});

module.exports = mongoose.model('Content', contentSchema);

content-category.js

コンテンツとカテゴリのリレーション

var mongoose      = require('../db');
var Schema        = mongoose.Schema

var contentCategorySchema = new Schema ({
  category_id  : { type: Number, refs: 'Category' },
  content_id   : { type: Number, refs: 'Content' }
});

module.exports = mongoose.model('ContentCategory', contentCategorySchema);

tag.js

タグ情報

var mongoose      = require('../db');
var Schema        = mongoose.Schema;

var tagSchema = new Schema ({
    site_id : { type: Number, refs: Site },
    tag_id  : Number,
    name    : String
});

tagSchema.plugin(mongoose.AutoIncrement, { inc_field: 'tag_id'});

module.exports = mongoose.model('Tag', tagSchema);

content-tag.js

var mongoose      = require('../db');
var Schema        = mongoose.Schema

var contentTagSchema = new Schema ({
  tag_id      : { type: Number, refs: 'Tag' },
  content_id  : { type: Number, refs: 'Content' }
});

module.exports = mongoose.model('ContenTag', contentTagSchema);

file.js

添付画像やアップロードされたファイルの情報

var mongoose      = require('../db');
var Schema        = mongoose.Schema;

var fileSchema = new Schema ({
  site_id: { type: Number, refs: Site },
  file_id: Number,
  user_id: { type: Number, refs: User},
  path   : String
});

fileSchema.plugin(mongoose.AutoIncrement, { inc_field: 'file_id' });

module.exports = mongoose.model('file', fileSchema);

まとめ

  • アプリケーション全体で共有する設定情報を config.js に定義
  • データベース接続およびmongoose共通環境を db.js に定義
  • モデル models/*.jsにモデルごとに定義
  • 各モデルはコントローラで使用するモデルの必要な分だけrequireする
  • データベース接続はpoolして使いまわす
  • アプリケーションでは自動採番フィールド(*_id)をキーにしてリレーションする
  • user.passwordは暗号化する
  • Index,Validationは別途行う

これで基本的なモデルの定義ができました。

:
├── models
│   ├── category-category.js
│   ├── category.js
│   ├── content-category.js
│   ├── content-tag.js
│   ├── content.js
│   ├── file.js
│   ├── site.js
│   ├── tag.js
│   ├── user-site.js
│   └── user.js
:

つづく

mongooseでpasswordを暗号化して保存してチェックする方法

Express+mongooseでパスワードを暗号化して保存する方法

mongoose-bcrypt をインストール

npm install mongoose-bcrypt --save

app.js

ルーティングの/ でデータ登録、/checkでパスワードをチェックしてみます

app.js

var mongoose = require('mongoose'); 
const option = {
  useMongoClient: true,
};
mongoose.Promise = global.Promise;
mongoose.connect(`mongodb://localhost/nexpress`, option); 

// スキーマ定義
var Schema        = mongoose.Schema;

var userSchema = new Schema ({
  user_id   : Number,
  login     : String,
  password  : String
});

userSchema.plugin(require('mongoose-bcrypt'),{ fields: ['password'] });
var User = mongoose.model('User', userSchema);

router.get('/', function(req, res, next) {
  var user = new User();
  user.name = '山田太郎';
  user.login = 'ichi-bit';
  user.password = 'PassPassPass';
  user.save();
  res.send('created user');
});

router.get('/check', function(req, res, next) {
  User.findOne({login: 'ichi-bit'}).then((user)=>{
    user.verifyPassword('PassPassPass').then(function(value){
      console.log('Encrypted password is ' + value);
      res.send('Encrypted password is ' + value);
    });
  });
});

動作確認

$ npm start
  1. localhost:3000 にアクセス created user
  2. localhost:3000/check Encrypted password is true

user.verifyPassword('PassPassPass')の部分のパスワードを変更すると Encrypted password is false となって動作していることがわかります。

備考

model.verifyXXXXX() のXXXXはUppercase のフィールド名で対象のフィールドをチェックできます

node.js+React CMS開発-サーバー雛形を作成

CMS開発シリーズの続きです

今回は Expressのプロジェクト作成とviewエンジンにReactを使用するテンプレートを作成します

目次

開発環境

node.js v9.2.0

$ nodebrew ls
$ nodebrew ls
v8.6.0
v8.7.0
v8.8.1
v9.0.0
v9.1.0
v9.2.0

current: v9.2.0
$ node -v
v9.2.0

mongodb 3.4.9

$ mongo
MongoDB shell version v3.4.10
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.9
> db.version()
3.4.9
>

express プロジェクト作成

$ express --version
4.15.5
$ express nexpress
$ cd ~/
$ express nexpress

  warning: the default view engine will not be jade in future releases
  warning: use `--view=jade' or `--help' for additional options


   create : nexpress
   create : nexpress/package.json
   create : nexpress/app.js
   create : nexpress/public
   create : nexpress/routes
   create : nexpress/routes/index.js
   create : nexpress/routes/users.js
   create : nexpress/views
   create : nexpress/views/index.jade
   create : nexpress/views/layout.jade
   create : nexpress/views/error.jade
   create : nexpress/bin
   create : nexpress/bin/www
   create : nexpress/public/javascripts
   create : nexpress/public/images
   create : nexpress/public/stylesheets
   create : nexpress/public/stylesheets/style.css

   install dependencies:
     $ cd nexpress && npm install

   run the app:
     $ DEBUG=nexpress:* npm start

初期パッケージインストール

基本環境となる依存パッケージをインストールします。 現時点で考えられるものだけをインストール。機能開発時に各々で必要なパッケージを導入していきます。

express-react-views

viewエンジンはReactにする

 $ npm install express-react-views react react-dom --save
 npm notice created a lockfile as package-lock.json. You should commit this file.
 + react@16.1.1
 + react-dom@16.1.1
 + express-react-views@0.10.4
 added 133 packages in 7.6s

mongoose

mongodbをODM化するミドルウェア

$ npm install mongoose --save
+ mongoose@4.13.2
added 28 packages in 3.995s

passport

ユーザー認証はpassportを使う(strategyは該当機能開発時に都度導入)

$ npm install passport --save
+ passport@0.4.0
added 3 packages in 1.871s

pagkage.json

dev用のサーバー起動スクリプトもscriptsに追加

{
  "name": "nexpress",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www",
    "dev": "DEBUG=nexpress:* node-dev ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.18.2",
    "cookie-parser": "~1.4.3",
    "debug": "~2.6.9",
    "express": "~4.15.5",
    "express-react-views": "^0.10.4",
    "jade": "~1.11.0",
    "mongoose": "^4.13.2",
    "morgan": "~1.9.0",
    "passport": "^0.4.0",
    "react": "^16.1.1",
    "react-dom": "^16.1.1",
    "serve-favicon": "~2.4.5"
  }
}

アプリケーション雛形を作成

app.js に view engineをReact仕様にする設定を追加、express-generatorで作成されたjade環境は削除します。 またviews/以下の .jade を .jsxにリネームして中身も変更します。

jade 削除

$ npm uninstall jade
removed 44 packages in 1.096s

.jade を .jsxにrename

views
├── error.jsx
├── index.jsx
└── layout.jsx

app.js

viewにReactを使用する

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');


var index = require('./routes/index');
var users = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jsx');
app.engine('jsx', require('express-react-views').createEngine());

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error',{title:"Error Page"});
});

module.exports = app;

views/layout.jsx

ページレイアウトのデフォルト。 デザインおよび要素の追加はは後ほど整えるとして以下のような初期設定(タイトル表示と現存するRouteコントローラへのリンク)をする

var React = require('react');

class Layout extends React.Component {
  render() {
    return (
      <html>
        <head><title>{this.props.title}</title></head>
        <body>
          <header>
            <h1>nexpress</h1>
            <nav>
              <ul>
                <li><a href="/">index</a></li>
                <li><a href="/users">users</a></li>
              </ul>
            </nav>
          </header>
          {this.props.children}
        </body>
      </html>
    );
  }
}

module.exports = Layout;

views/index.jsx

トップページ
タイトルを表示するだけのシンプルなテンプレート

var React = require('react');
var Layout = require('./layout');

class Index extends React.Component {
  render() {
    return (
      <Layout title={this.props.title}>
        <div>
          <h2>{this.props.title}</h2>
        </div>
      </Layout>
    );
  }
}

module.exports = Index;

views/error.jsx

express-generatorでデフォルト作成された error.jadeに習って同じような表示を継承

var React = require('react');
var Layout = require('./layout');

class Error extends React.Component {
  render() {
    return (
      <Layout title={this.props.title}>
        <div>
          <h1>{this.props.message}</h1>
          <h2>{this.props.error.status}</h2>
          <pre>
            {this.props.error.stack}
          </pre>
        </div>
      </Layout>
    );
  }
}

module.exports = Error;

起動&確認

起動

npm startもしくは上で追加したdev用スクリプトnpm run dev

$ cd ~/nexpress
$  $ npm start
nexpress@0.0.0 start /Users/haranaga/nexpress
node ./bin/www

ブラウザで確認

URLにアクセスして以下画面が表示されればOK

http://localhost:3000/

f:id:ichi-bit:20171115163310p:plain

これでnode.js+React 環境のアプリケーションの雛形ができました。 ソースはこちら(リポジトリのタグ template-react-view)

github.com

つづく

mongooseでauto increment のフィールド定義と動作確認

Express+mongooseで RDBのようなAUTO INCREMENTフィールドを実現する

mongoose-sequence パッケージをインストール

$ npm install --save mongoose-sequence

app.js

var mongoose = require('mongoose'); 
const option = {
  useMongoClient: true,
};
mongoose.Promise = global.Promise;
mongoose.connect(`mongodb://localhost/nexpress`, option); 

// スキーマ定義
var Schema        = mongoose.Schema;
const AutoIncrement = require('mongoose-sequence')(mongoose);

var siteSchema = new Schema ({
  site_id: Number,
  name   : String,
  title  : String
});

// site_id フィールドをAutoIncrementする
siteSchema.plugin(AutoIncrement, { inc_field: 'site_id'});
var Site = mongoose.model('Site', siteSchema);

app.get('/', function(req, res ) {
  site = new Site();
  site.name = 'hoge';
  site.title = 'moge';
  site.save();

  res.send('respond with a resource');
});

ブラウザで / にアクセス 10回ほど

db確認

$ mongo
> use nexpress
switched to db nexpress
> db.sites.find();
{ "_id" : ObjectId("5a0c271cb830a7e523d7bedd"), "site_id" : 1, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c272db830a7e523d7bede"), "site_id" : 2, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c2766001be7e530483144"), "site_id" : 3, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c2769001be7e530483145"), "site_id" : 4, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c29584a2a12e5796ffc10"), "site_id" : 5, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c295a4a2a12e5796ffc11"), "site_id" : 6, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c295d4a2a12e5796ffc12"), "site_id" : 7, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c295f4a2a12e5796ffc13"), "site_id" : 8, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c29614a2a12e5796ffc14"), "site_id" : 9, "title" : "moge", "name" : "hoge", "__v" : 0 }
{ "_id" : ObjectId("5a0c29634a2a12e5796ffc15"), "site_id" : 10, "title" : "moge", "name" : "hoge", "__v" : 0 }
>

備考

siteSchema.plugin(AutoIncrement, { inc_field: 'site_id'});

ここで inc_fieldを指定しない場合は _id が AIフィールドになるらしい

node.js+React CMS を設計する(データベース仕様)

nexpressスキーマ構成を定義する。
必要最低限の要件を元にシンプルな構造を定義する。

f:id:ichi-bit:20171114180334p:plain

1. Schemaに対する要件

  • ユーザー登録がエントリーポイント
  • ユーザー
    • ユーザーはサイトを複数作成できる
    • サイトを新規作成したとき、自動的にそのサイトの最高権限ユーザーとなる
    • ユーザーは自他含め複数のサイトに属する事ができる
    • サイト内での権限(role)を付与される
  • カテゴリ
    • サイト単位でカテゴリを定義できる
    • カテゴリは階層構造を取る
    • カテゴリは他のカテゴリの子となることができる
  • タグ
    • サイト単位でタグを定義できる
    • タグは短いキーワードとして登録する
  • コンテンツ
    • コンテンツはサイトおよび作成したユーザーに属す
    • コンテンツには公開期間を設定できる
    • コンテンツは複数のカテゴリに属することができ、かつカテゴリをキーとして検索できる
    • コンテンツにはタグを付与でき、かつタグをキーとして検索できる

2. Schema定義

user

ユーザー情報

フィールド名 概要
user_id String ユーザーID、登録時に自動生成
login String ログインアカウント
password String パスワード、暗号化して格納
name String 表示名

site

サイト情報

フィールド名 概要
site_id String サイトID、登録時に自動生成
name String 管理用名称
title String 表示用サイトタイトル

user_site

ユーザーとサイトのリレーション

フィールド名 概要
site_id String サイトID外部キー
user_id String ユーザーID外部キー
role String 権限

content

コンテンツ情報

フィールド名 概要
content_id String 記事ID、作成時に自動生成
site_id String サイトID外部キー
user_id String ユーザーID外部キー
name String 表示用のカテゴリ名称
title String 記事タイトル
summary String 記事サマリー、リード文
image String アイキャッチ画像のアップロードパス
publish_date Date 掲載日時
expire_date Date 掲載終了
article String 記事本文(JSON形式で本文構造を保存する予定もしくはembed)

category

カテゴリ情報

フィールド名 概要
category_id String カテゴリID、作成時に自動生成
site_id String サイトID外部キー
name String 表示用のカテゴリ名称
slug String カテゴリのURLスラッグ

category_category

カテゴリ間のリレーション

フィールド名 概要
parent_category_id String カテゴリID、外部キー、親のカテゴリID
category_id String カテゴリID、外部キー

content_category

記事とカテゴリ間のリレーション

フィールド名 概要
content_id String 記事ID、外部キー
category_id String カテゴリID、外部キー

tag

タグ情報

フィールド名 概要
site_id String サイトID外部キー
tag_id String タグID、作成時に自動生成
name String タグワード

content_tag

記事とタグ間のリレーション

フィールド名 概要
content_id String 記事ID、外部キー
tag_id String タグID、外部キー

つづく

node.js+React CMS を設計する(外部仕様)

nexpress(CMS名称)画面構成 f:id:ichi-bit:20171114122033p:plain

1. サイト画面構成

SSR(Server Side Rendering)にする

  1. トップページ
  2. 記事リストページ
    1. カテゴリ(サブカテゴリ)
    2. 検索結果
    3. タグ検索
    4. ランキング
    5. 新着
    6. その他条件リスト
  3. 記事本文ページ
  4. (ユーザープロフィールや固定ページ)

2. 管理画面構成

SPA(Single Page Application)にする

  1. Adminトップページ
  2. ユーザー登録
  3. ログイン
  4. ダッシュボード
  5. サイト管理
  6. デザイン管理
  7. ユーザー管理
  8. カテゴリ管理
  9. タグ管理
  10. 記事管理
  11. 編集
  12. プレビュー

3. 外部仕様

全画面共通

  • PC,tablet向け、SP向け(HTML,AMP)に表示できるようにする
  • PC,SPを分けるかレスポンシブにするか選択可能にする

サイト

  • 全ページ共通
    • ヘッダー
    • フッター
    • 広告のタグ類
    • その他全ページ共通表示要素
  • トップページ
    • 新着記事など様々な条件で抽出したリストを複数表示。指定した順にに表示される
    • 記事リストには記事のアイキャッチとタイトル、本文の一部、カテゴリ名が表示される
    • その他誘導するためのリンクを表示
  • 記事本文
    • 本文には以下の要素を含を含む
      • 見出し
      • 小見出し
      • 画像、画像キャプション、Youtube、Markdown、画像スライド、記事引用、リンク
    • 本文をページ分け
    • 記事のPVをカウントする
    • その他記事への誘導
    • SNSへの連携
  • 新着、ランキング、カテゴリ、検索、タグ検索記事リスト用途のページ
    • 各条件で抽出した記事リストを表示する

つづく

node.js+ReactベースのCMS開発をはじめます

なぜCMSか

現在CMSのデファクトスタンダードはwordpressです。PHPが動作するサーバーであれば簡単に設置でき、豊富なプラグイン、デザインテーマがあって使いやすくなっています。 これに匹敵できるようなCMSをnode.jsベースで開発してみます。CMSはWebアプリケーションの機能を幅広く実装するため、開発事例を網羅するためにCMSを題材に選びました。

なぜnode.jsか

Webアプリケーションの開発においてフロントエンドの複雑化に対応することが必須となっています。 フロントエンドで動的な機能を実現するためにJavascriptの使用が標準となるため、バックエンドの実装言語にかかわらずJavascriptは必ず通る道です。 それならばバックエンドもJavascriptで構築すべきと考えました。node.jsは一般的に言われているメリットもあるためnode.jsをベースとします。 使用言語をjavascriptに統一することによりエンジニアの人員採用のチャンスも大きくなります。

なぜReactか

よく比較されるAngular,Vueなど選択肢があるなか、どれをとっても有用な点があり、一概にどれが良いと言うことができません。 個人的にはとっつきやすいVueが好みですが、スマホアプリの開発を視野に入れた場合にReact Nativeの活用が考えられるため、Reactを使います。 またcreate-react-appのリリースにより開発環境が簡単に作れることもReactを使う理由です。 今回開発するCMSは画面のカスタマイズを柔軟かつ安定的にするためweb部品をコンポーネント化することも念頭にあり、効果的に利用できそうです。

開発コンセプト

  1. node.js ベース
  2. React を利用
  3. 独自コードをなるべく少なくするためスタンダードなライブラリを利用する
  4. 大規模組織、大規模トラフィックを対象とする
  5. すべてjavascriptで構築
  6. 運用インフラはAWSを想定
  7. 開発環境はMacOSのlocal環境
  8. OSSとして公開

開発の進め方

  • 実際の設計手順で行います。
  • 企画、機能要件は最初に決めずに進行中でも変更可能にします。

プロジェクトの名前

nexpressとします

つづく

React(create-react-app)とExpress(REST API)で画像アップ掲示板を作ってみる(2)

掲示板の続きです。

これまではテキストのみを投稿する掲示板でしたが、画像もアップできるようにします。 前回とExpress(REST_API)で画像アップ掲示板を作ってみる(1))はサーバーサイドのAPIをファイルアップロードに対応しました。

今回はフロントエンドのアップロードインターフェースをReactで実装します。

作るもの

  • これまで作ってきた掲示板を拡張
  • フロントエンドはcreate-react-appで作成
  • バックエンドはExpress
  • 書き込みと同時に画像をアップロードできるようにする
  • 書き込み一覧に画像を表示する

0. これまで作ったファイル構成

kakikomi
  ├── kakiapi                     サーバー API
  │   ├── app.js
  │   ├── bin
  │   ├── node_modules
  │   ├── package-lock.json
  │   ├── package.json
  │   ├── public
  │   ├── routes
  │   └── views
  └── kakifront                   フロントエンド
      ├── README.md
      ├── build 
      ├── node_modules
      ├── package-lock.json
      ├── package.json
      ├── public
      └── src

作業は kakikomi/ 以下で行います

1. サーバーサイドAPIのapp.js全部

kakiapi/app.js

var express = require('express');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var mongoose = require('mongoose');
var restify = require('express-restify-mongoose');

var app = express();
var router = express.Router();

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static('public'));

mongoose.connect('mongodb://localhost/app1',{useMongoClient:true});
restify.serve(router, mongoose.model('Post', new mongoose.Schema({
  name: { type: String },
  kakikomi: { type: String },
  filename: {type: String}
})));
app.use(router);

/////////////////////////
///  追加
/////////////////////////
var path = require('path');  // ファイルの拡張子を取得するのに使う
var multer  = require('multer');
// 格納場所と新しくつけるファイル名の定義
var storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, 'public/uploads');
  },
  filename: function (req, file, cb) {
    cb(null, file.fieldname + '_' + Date.now() + path.extname(file.originalname));
  }
});
var upload = multer({ storage: storage });
// ルーティング
app.post('/api/upload', upload.single('image'), function (req, res, next) {
  delete req.file.buffer; // responseには入れない
  res.json(req.file); // 取得した情報を返す
});
/////

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.send({error:'error'});
});

module.exports = app;

説明

  • データベースはmongodb
  • ODMはmongoose
  • 掲示板用のコレクションはpostsでフィールドはkakikomi,name,filenama
  • filename にアップされた画像のパスを保存する
  • ファイルアップロード用のミドルウェアはmulter
  • /api/upload にファイルをPOSTされると画像を保存して、ファイル情報を返却する

2. フロントエンド(create-react-app)の実装

以前作成したテキスト版掲示板のフロントエンドを拡張します

kakifront/src/Api.js

import React, { Component } from 'react';
import './App.css';


/**
* 画像アップ用のコントロール
*/
class ImageUpload extends Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange = (e) => {
    e.preventDefault();
    var form = new FormData();
    form.append('image',e.target.files[0]);

    fetch('/api/upload', {
      method: "POST",
      body: form
    }).then(
      response => response.json()
    ).then(
      data => {
      this.props.onChange(data.filename);
    });
  }

  render() {
    return (
      <div>
        <input type="file" name="image" onChange={this.handleChange} />
      </div>
    );
  }
}

/**
* 書き込み1件
*/
class Post extends Component {
  render() {
    var image = '';
    if (this.props.post.filename) {
      image = (<img src={`http://localhost:3000/uploads/${this.props.post.filename}`} alt={this.props.post.filename} />);
    }
    return (
      <div className="kakikomi">
        <div>{this.props.post.kakikomi}</div>
        {image}
        <p className="name">{this.props.post.name}</p>
      </div>
    );
  }
}

/**
* 書き込みリスト
*/
class List extends Component {
  // 書き込みリスト
  render() {
    const posts = this.props.posts;
    var list = [];
    for (var i in posts) {
      list.push( <li key={i}><Post post={posts[i]} /></li> );
    }
    return (<ul>{list}</ul>);
  }
}

/**
* 投稿フォーム
*/
class UpForm extends Component {

  constructor(props) {
    super(props);
    this.state = {
      kakikomi:'',
      name:'',
      message:'書き込んでください',
      filename: ''
    };
    this.handleSubmit = this.handleSubmit.bind(this);
    this.handleChange = this.handleChange.bind(this);
    this.handleFile = this.handleFile.bind(this);
  }

  handleSubmit = (e) => {
    e.preventDefault();
    fetch('/api/v1/Post', {
      method : "POST",
      headers: {
        "Content-Type": "application/json",
        "Accept": "application/json"
      },
      body: JSON.stringify({
        kakikomi: this.state.kakikomi,
        name : this.state.name,
        filename: this.state.filename
      })
    }).then(data => {
      this.setState({
        message:'アップしました',
        kakikomi: '',
        name: '',
        filename: ''
      });
    }).then(() => {this.props.onSubmit()});
  }

  handleChange = (e) => {
    this.setState({message:''});
    this.setState({ [e.target.name] :e.target.value});
  }

  handleFile = (filename) => {
    console.log(filename);
    this.setState({filename : filename});
  }

  render() {
    return  (
      <div className="form">
        <p>{this.state.message}</p>
        <form onSubmit={this.handleSubmit}>
          <label>内容</label>
          <textarea name="kakikomi" value={this.state.kakikomi} onChange={this.handleChange}></textarea>
          <label>名前</label>
          <input type="text" name="name" value={this.state.name}  onChange={this.handleChange} />
          <button type="submit">投稿</button>
        </form>
        <label>画像</label>
        <ImageUpload onChange={this.handleFile}/>
      </div>
    );
  }
}

/**
* ヘッダー
*/
class Header extends Component {
  render() {
    return (
      <header>
      <h1>掲示板</h1>
      </header>
    );
  }
}

/**
* 全画面
*/
class App extends Component {

  constructor(props) {
    super(props);
    this.state = {
      posts: []
    };
    this.updatePosts();

    this.updatePosts = this.updatePosts.bind(this);
  }

  updatePosts(e) {
    getPost((data) => {
      this.setState({posts: data});
    });
  }

  render() {
    // this.updatePosts();
    return (
      <div className="App">
      <Header />
      <UpForm onSubmit={this.updatePosts}/>
      <List posts={this.state.posts}/>
      </div>
    );
  }
}

/**
* サーバーから書き込み一覧を取得する
* @method getPost
* @param  {Function} callback データ取得後のコールバック
* @return {[type]}
*/
function getPost(callback) {
  fetch('/api/v1/Post?sort={"_id":-1}')
  .then(response => response.json())
  .then((data) => {callback(data)});
}

export default App;

やったこと

  • 画像アップ用のフォームコントロールImageUploadを追加
    • 書き込み用のForm(UpForm)の外に配置してファイル指定のイベントで先にアップロードしてサーバーファイルパスを返す
  • UpFormではstateにfilenameをもち、アップロード先のファイルパスを保持するようにする
  • 書き込み投稿時に内容、名前とファイルパスをサーバーAPIにポストする
  • 表示時にfilenameがあればimageタグで画像を表示する
    • この際画像をホスティングしているのはサーバーAPIなので localhost:3000をURLに利用
    • サーバーサイドAPIではstaticファイルのホスティングをpublicディレクトリに指定してある

これで画像アップロード掲示板ができました。

3. まとめ

  • サーバーAPIとフロントエンドを分けて作りました
  • サーバーAPIは Express+MongoDB
    • mongoose(モデル)
    • express-restify-mongoose(モデル定義をCRUD REST API化)
    • multer(アップロード)
  • フロントエンド
    • create-react-app
  • サーバーをlocalhost:3000で起動
  • フロントエンドはlocalhost:3001テスト

ソース

ソースファイルはこちらです(tag:v2) github.com

©ichi-bit