用 Express 和 MongoDB 寫一個 todo list

練習一種語言或是 framework 最快的入門方式就是寫一個 todo list 了. 他包含了基本的 C.R.U.D. ( 新增, 讀取, 更新, 刪除 ). 這篇文章將用 node.js 裡最通用的 framework Express 架構 application 和 MongoDB 來儲存資料.

**更新**

Express 目前為 4.x,和下面教學的 3.x 不太一樣。我更新了在 Github 上的原始碼,你可以參考比較一下。



原始檔

請見 github | Download | Live Demo

功能

  • 無需登入, 用 cookie 來辨別每一問使用者
  • 可以新增, 讀取, 更新, 刪除待辦事項( todo item )

安裝

開發環境

開始之前請確定你已經安裝了 node.js, ExpressMongoDB, 如果沒有可以參考下列文章.


node.js 套件

參考文件 : npm basic commands
  • 安裝 Express
$ npm install express@3.4.7 -g

這個練習裡我們用 Mongoose 這個 ORM. 為何會需要一個必須定義 schema 的 ORM 來操作一個 schema-less 的資料庫呢? 原因是在一般的網站資料結構的關聯, 驗證都是必須處理的問題. Mongoose 在這方面可以幫你省去很多功夫. 我們會在後面才看如何安裝.

步驟

用 Express 的 command line 工具幫我們生成一個 project 雛形

預設的 template engine 是 jade, 在這裡我們改用比較平易近人的 ejs.
$ express todo -e

create : todo
create : todo/package.json
create : todo/app.js
create : todo/public
create : todo/public/javascripts
create : todo/public/stylesheets
create : todo/public/stylesheets/style.css
create : todo/routes
create : todo/routes/index.js
create : todo/routes/user.js
create : todo/public/images
create : todo/views
create : todo/views/index.ejs

在專案根目錄增加 .gitignore 檔案

.DS_Store
node_modules
*.sock

ejs-locals 以及 mongoose 加入 dependencies

Express 3.0 之後移除了 layout 和 partial support, 所以我們要增加 ejs-locals 這個套件才能有這些功能. 另外你也需要新增 layout.ejs 到 view 資料夾.

編輯 package.json
{
  "name"         : "todo",
  "version"      : "0.0.1",
  "private"      : true,
  "dependencies" : {
    "express"    : "3.4.7",
    "ejs"        : "0.8.5",
    "ejs-locals" : "1.0.2",
    "mongoose"   : "3.8.1"
  }
}

安裝 dependencies

$ cd todo && npm install

Hello world

開啟 express server 然後打開瀏覽器瀏覽 127.0.0.1:3000 就會看到歡迎頁面.
$ node app.js

Project 檔案結構

todo
|-- node_modules
|   |-- ejs
|   |-- ejs-locals
|   |-- express
|   `-- mongoose
|
|-- public
|   |-- images
|   |-- javascripts
|   `-- stylesheets
|       |-- style.css
|
|-- routes
|   `-- index.js
|
|-- views
|   |-- index.ejs
|   `-- layout.ejs
|
|-- .gitignore
|
|-- app.js
|
`-- package.json
  • node_modules
    • 包含所有 project 相關套件.
  • public
    • 包含所有靜態檔案.
  • routes
    • 所有動作包含商業邏輯.
  • views
    • 包含 action views, partials 還有 layouts.
  • app.js
    • 包含設定, middlewares, 和 routes 的分配.
  • package.json
    • 相關套件的設定檔.

MongoDB 以及 Mongoose 設定

Ubuntu 上 MongoDB 開機後便會自動開啟. 在 Mac 上你需要手動輸入下面的指令.
$ mongod
在根目錄下新增一個檔案叫做 db.js 來設定 MongoDB 和定義 schema.
var mongoose = require( 'mongoose' );
var Schema   = mongoose.Schema;

var Todo = new Schema({
    user_id    : String,
    content    : String,
    updated_at : Date
});

mongoose.model( 'Todo', Todo );
mongoose.connect( 'mongodb://localhost/express-todo' );
app.js 裡 require 他.
require( './db' );
移除 Express 產生用不到的 user route 還有加上 layout support.
// mongoose setup
require( './db' );

var express = require( 'express' );
var routes  = require( './routes' );
var http    = require( 'http' );
var path    = require( 'path' );
var app     = express();
var engine  = require( 'ejs-locals' );

// all environments
app.set( 'port', process.env.PORT || 3001 );
app.engine( 'ejs', engine );
app.set( 'views', path.join( __dirname, 'views' ));
app.set( 'view engine', 'ejs' );
app.use( express.favicon());
app.use( express.logger( 'dev' ));
app.use( express.json());
app.use( express.urlencoded());
app.use( express.methodOverride());
app.use( app.router );
app.use( express.static( path.join( __dirname, 'public' )));

// development only
if ( 'development' == app.get( 'env' )) {
  app.use( express.errorHandler());
}

app.get( '/', routes.index );

http.createServer( app ).listen( app.get( 'port' ), function(){
  console.log( 'Express server listening on port ' + app.get( 'port' ));
} );

修改 project title

routes/index.js
exports.index = function ( req, res ){
  res.render( 'index', { title : 'Express Todo Example' });
};

修改 index view

我們需要一個 text input 來新增待辦事項. 在這裡我們用 POST form 來傳送資料. 另外別忘了設定 layout.
views/index.ejs
<% layout( 'layout' ) -%>

<h1><%= title %></h1>
<form action="/create" method="post" accept-charset="utf-8">
  <input type="text" name="content" />
</form>

新增待辦事項以及存檔

routes/index.js
首先先 require mongooseTodo model.
var mongoose = require( 'mongoose' );
var Todo     = mongoose.model( 'Todo' );
新增成功後將頁面導回首頁.
exports.create = function ( req, res ){
  new Todo({
    content    : req.body.content,
    updated_at : Date.now()
  }).save( function( err, todo, count ){
    res.redirect( '/' );
  });
};
將這個新增的動作加到 routes 裡.
app.js
// 在 app.use( express.json()); 之前新增下列語法
app.use( express.bodyParser());
// 新增下列語法到 routes
app.post( '/create', routes.create );

顯示待辦事項

routes/index.js
// 查詢資料庫來取得所有待辦是事項.
exports.index = function ( req, res ){
  Todo.find( function ( err, todos, count ){
    res.render( 'index', {
        title : 'Express Todo Example',
        todos : todos
    });
  });
};
views/index.ejs
// 在最下面跑迴圈來秀出所有待辦事項.
<% todos.forEach( function( todo ){ %>
  <p><%= todo.content %></p>
<% }); %>

刪除待辦事項

在每一個待辦事項的旁邊加一個刪除的連結.
routes/index.js
// 根据待辦事項的 id 来移除他
exports.destroy = function ( req, res ){
  Todo.findById( req.params.id, function ( err, todo ){
    todo.remove( function ( err, todo ){
      res.redirect( '/' );
    });
  });
};
views/index.ejs
// 在迴圈裡加一個删除連結
<% todos.forEach( function ( todo ){ %>
  <p>
    <span>
      <%= todo.content %>
    </code>
    <span>
      <a href="/destroy/<%= todo._id %>" title="Delete this todo item">Delete</a>
    </code>
  </p>
<% }); %>
將這個刪除的動作加到 routes 裡.
app.js
// 新增下列語法到 routes
app.get( '/destroy/:id', routes.destroy );

編輯待辦事項

當滑鼠點擊待辦事項時將他轉成一個 text input.
routes/index.js
exports.edit = function ( req, res ){
  Todo.find( function ( err, todos ){
    res.render( 'edit', {
        title   : 'Express Todo Example',
        todos   : todos,
        current : req.params.id
    });
  });
};
Edit view 基本上和 index view 差不多, 唯一的不同是在選取的那個待辦事項變成 text input.
views/edit.ejs
<% layout( 'layout' ) -%>

<h1><%= title %></h1>
<form action="/create" method="post" accept-charset="utf-8">
  <input type="text" name="content" />
</form>

<% todos.forEach( function ( todo ){ %>
  <p>
    <span>
      <% if( todo._id == current ){ %>
      <form action="/update/<%= todo._id %>" method="post" accept-charset="utf-8">
        <input type="text" name="content" value="<%= todo.content %>" />
      </form>
      <% }else{ %>
        <a href="/edit/<%= todo._id %>" title="Update this todo item"><%= todo.content %></a>
      <% } %>
    </code>
    <span>
      <a href="/destroy/<%= todo._id %>" title="Delete this todo item">Delete</a>
    </code>
  </p>
<% }); %>
將待辦事項包在一個 link 裡, link 可以連到 edit 動作.
views/index.ejs
<% layout( 'layout' ) -%>

<h1><%= title %></h1>
<form action="/create" method="post" accept-charset="utf-8">
  <input type="text" name="content" />
</form>

<% todos.forEach( function ( todo ){ %>
  <p>
    <span>
      <a href="/edit/<%= todo._id %>" title="Update this todo item"><%= todo.content %></a>
    </code>
    <span>
      <a href="/destroy/<%= todo._id %>" title="Delete this todo item">Delete</a>
    </code>
  </p>
<% }); %>
將這個編輯的動作加到 routes 裡.
app.js
// 新增下列語法到 routes
app.get( '/edit/:id', routes.edit );

更新待辦事項

新增一個 update 動作來更新待辦事項.
routes/index.js
// 結束後重新導回首頁
exports.update = function ( req, res ){
  Todo.findById( req.params.id, function ( err, todo ){
    todo.content    = req.body.content;
    todo.updated_at = Date.now();
    todo.save( function ( err, todo, count ){
      res.redirect( '/' );
    });
  });
};
將這個更新的動作加到 routes 裡.
app.js
// 新增下列語法到 routes
app.post( '/update/:id', routes.update );

排序

現在待辦事項是最早產生的排最前面, 我們要將他改為最晚產生的放最前面.
routes/index.js
exports.index = function ( req, res ){
  Todo.
    find().
    sort( '-updated_at' ).
    exec( function ( err, todos ){
      res.render( 'index', {
          title : 'Express Todo Example',
          todos : todos
      });
    });
};

exports.edit = function ( req, res ){
  Todo.
    find().
    sort( '-updated_at' ).
    exec( function ( err, todos ){
      res.render( 'edit', {
          title   : 'Express Todo Example',
          todos   : todos,
          current : req.params.id
      });
    });
};

多重使用者

現在所有使用者看到的都是同一份資料. 意思就是說每一個人的 todo list 都長得一樣, 資料都有可能被其他人修改. 我們可以用 cookie 來記錄使用者資訊讓每個人有自己的 todo list. Express 已經有內建的 cookie, 只要在 app.js 新增一個 middleware 就好. 另外我們也會需要新增一個依據 cookie 來抓取當下的使用者的 middleware.

app.js
// mongoose setup
require( './db' );

var express = require( 'express' );
var routes  = require( './routes' );
var http    = require( 'http' );
var path    = require( 'path' );
var app     = express();
var engine  = require( 'ejs-locals' );

// all environments
app.set( 'port', process.env.PORT || 3001 );
app.engine( 'ejs', engine );
app.set( 'views', path.join( __dirname, 'views' ));
app.set( 'view engine', 'ejs' );
app.use( express.favicon());
app.use( express.logger( 'dev' ));
app.use( express.cookieParser());
app.use( express.bodyParser());
app.use( express.json());
app.use( express.urlencoded());
app.use( express.methodOverride());
app.use( routes.current_user );
app.use( app.router );
app.use( express.static( path.join( __dirname, 'public' )));

// development only
if( 'development' == app.get( 'env' )){
  app.use( express.errorHandler());
}

// Routes
app.get(  '/',            routes.index );
app.post( '/create',      routes.create );
app.get(  '/destroy/:id', routes.destroy );
app.get(  '/edit/:id',    routes.edit );
app.post( '/update/:id',  routes.update );

http.createServer( app ).listen( app.get( 'port' ), function (){
  console.log( 'Express server listening on port ' + app.get( 'port' ));
});
routes/index.js
var utils    = require( '../utils' );
var mongoose = require( 'mongoose' );
var Todo     = mongoose.model( 'Todo' );

exports.index = function ( req, res, next ){
  var user_id = req.cookies ?
    req.cookies.user_id : undefined;

  Todo.
    find({ user_id : user_id }).
    sort( '-updated_at' ).
    exec( function ( err, todos ){
      if( err ) return next( err );

      res.render( 'index', {
          title : 'Express Todo Example',
          todos : todos
      });
    });
};

exports.create = function ( req, res, next ){
  new Todo({
      user_id    : req.cookies.user_id,
      content    : req.body.content,
      updated_at : Date.now()
  }).save( function ( err, todo, count ){
    if( err ) return next( err );

    res.redirect( '/' );
  });
};

exports.destroy = function ( req, res, next ){
  Todo.findById( req.params.id, function ( err, todo ){
    var user_id = req.cookies ?
      req.cookies.user_id : undefined;

    if( todo.user_id !== req.cookies.user_id ){
      return utils.forbidden( res );
    }

    todo.remove( function ( err, todo ){
      if( err ) return next( err );

      res.redirect( '/' );
    });
  });
};

exports.edit = function( req, res, next ){
  var user_id = req.cookies ?
      req.cookies.user_id : undefined;

  Todo.
    find({ user_id : user_id }).
    sort( '-updated_at' ).
    exec( function ( err, todos ){
      if( err ) return next( err );

      res.render( 'edit', {
        title   : 'Express Todo Example',
        todos   : todos,
        current : req.params.id
      });
    });
};

exports.update = function( req, res, next ){
  Todo.findById( req.params.id, function ( err, todo ){
    var user_id = req.cookies ?
      req.cookies.user_id : undefined;

    if( todo.user_id !== user_id ){
      return utils.forbidden( res );
    }

    todo.content    = req.body.content;
    todo.updated_at = Date.now();
    todo.save( function ( err, todo, count ){
      if( err ) return next( err );

      res.redirect( '/' );
    });
  });
};

// ** 注意!! express 會將 cookie key 轉成小寫 **
exports.current_user = function ( req, res, next ){
  var user_id = req.cookies ?
      req.cookies.user_id : undefined;

  if( !user_id ){
    res.cookie( 'user_id', utils.uid( 32 ));
  }

  next();
};

Error handling

要處理錯誤我們需要新增 next 參數到每個 action 裡. 一旦錯誤發生遍將他傳給下一個 middleware 去處理.

routes/index.js
... function ( req, res, next ){
  // ...
};

...( function( err, todo, count ){
  if( err ) return next( err );

  // ...
});

Run application

$ node app.js

到次為止我們已經完成了大部分的功能了. 原始碼裡有多加了一點 css 讓他看起來更美觀. 趕快開啟你的 server 來玩玩看吧 :)

Related posts