用 Express & Sequelize 打造 MVC 餐廳網站(下)


Posted by Nicolakacha on 2020-10-25

上篇文章用 Express & Sequelize 打造 MVC 餐廳網站(上)已經完成了餐廳網站的靜態網站版本,接下來就要來出所有的功能啦,首先,因為之後的各項功能都需要存取資料庫,所以先來處理連線和存取 MySQL 的部份,透過 Sequelize 這套 ORM 工具來連線資料庫並在資料庫內建立 tables。

ORM 工具 - Sequelize

ORM(Object–relational mapping) 是幫助開發者能使用程式語言的物件概念來操作資料庫的工具,透過 ORM 就可以不需要直接寫 SQL 語法,今天要使用的 Sequelize 就是一套 ORM 工具~

照著官方文件安裝並初始化完 Sequelize-Cli、並設定好連線資料庫的 config 之後,就可以透過 cli 指令來快速建立 model。

建立 user 的 model:

// command line
npx sequelize-cli model:generate --name User --attributes username:string,password:string

其他 model 也依序建立,總共需要建立 user, menus, prizes, questions 這四個 model。model 都建立完成之後,就要使用 command line 執行 migration 來在 MySQL 的資料庫中真正建立 tables:

// command line
npm run migrate

執行完後可以發現 models 資料夾的 index.js 也被更新了,MySQL 的 tables 也都建好了,就是這麼輕鬆,就是這麼容易。連上資料庫之後,再來回顧一下餐廳網站需要做哪些功能:

  • 註冊、登入、登出
  • 新增、編輯、刪除獎項
  • 新增、編輯、刪除菜單
  • 新增、編輯、刪除問題
  • 抽獎功能
  • 跨頁面購物車

註冊功能

// app.js
function back(req, res) {
  res.redirect('back');
}
app.post('/register', userController.handleRegister, back);

註冊功能需要在 userController 建立 handleRegister 這個 method 拿到前端表單中 POST 過來的 username, password, password again 三個欄位的資料:

在入口點 app.js 使用下列這兩個 Express 內建的 middleware 來拿到前端的表單及 JSON 資料:

// app.js
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

connection-flash
在資料填寫不完全的情況下,使用 connection-flash 這個 middleware 來儲存錯誤提示訊息,之後可以用來顯示在畫面上:

// /controllers/user.js
 handleRegister: (req, res, next) => {
    const { username, password, password2 } = req.body;
    if (!username || !password || !password2) {
      req.flash('errorMessage', '該填的沒填哦');
      return next(); // 返回原頁面
    }
    if (password !== password2) {
      req.flash('errorMessage', '輸入的密碼不一致');
      return next(); // 返回原頁面
    }

在入口點 app.js 引入 flash,並使用 res.locals 將 flash 存成 local,之後就可以將這個 flash 的值傳遞到所要渲染的 ejs template 中:

// app.js
app.use(flash());
app.use((req, res, next) => {
  res.locals.errorMessage = req.flash('errorMessage');
  next();
});

如果帳號和兩次密碼都有填寫正確,且使用者名稱沒有跟資料庫中的重複,就在 users table 裡面新增一筆 user 資料,但這裡的密碼需要先經過 hash 處理,所以使用 bcrypt 這個套件。

新增成功之後,就把 username 存進 SESSION 中,維持登入狀態,並重新導向到管理後台:

// /controllers/user.js
const bcrypt = require('bcrypt');
const saltRounds = 10;
const db = require('../models'); // 連線資料庫
const { User } = db; // 存取 users table

handleRegister: (req, res, next) => {
    const { username, password, password2 } = req.body;
    ...

    bcrypt.hash(password, saltRounds, (err, hash) => {
      // 錯誤處理
      if (err) {
        req.flash('errorMessage', err.toString());
        return next();
      }
      // 檢查是否已經有相同的使用者名稱
      User.findOne({
        where: {
          username,
        },
      }).then((user) => {
        // 新增使用者
        if (user === null || user.username !== username) {
          User.create({
            username,
            password: hash,
          })
            .then(() => {
              // 把 username 存進 SESSION 中,維持登入狀態,並重新導向到管理後台
              req.session.username = username;
              res.redirect('/manage');
            })
            .catch((err2) => {
              req.flash('errorMessage', err2.toString());
              return next();
            });
        } else {
          req.flash('errorMessage', '使用者已存在');
          return next();
        }
      });
    });
  },

在入口點 app.js 中,設定 manage 頁面渲染的 routing - middeleware,在渲染頁面之前,加入一個檢查是否 SESSION 中有 username 的 middleware,若有才將控制權交給渲染頁面的 middleware:

// app.js
function checkLogin(req, res, next) {
  if (!req.session.username) return res.redirect('/login');
  next();
}

app.get('/manage', checkLogin, userController.manage);

登入、登出功能

登入功能就是要拿到前端表單的使用者名稱及密碼,並和資料庫中的 user 比對,值得注意的是,這裡比對的密碼是要經過 bcrypt.compare 處理過 hash 的密碼,登入成功之後一樣導回到 /manage 路由:

// /controllers/user.js
handleLogin: (req, res, next) => {
    const { username, password } = req.body;
    if (!username || !password) {
      ...
    }
    User.findOne({
      where: {
        username,
      },
    })
      .then((user) => {
        if (!user) {
          ...
        }
        bcrypt.compare(password, user.password, (err, result) => {
          if (err || !result) {
            ...
          }
          req.session.username = user.username;
          res.redirect('/manage');
        });
      })
      .catch((err) => {
        ...
      });
  },

而登出功能就只是把 SESSION 中的 username 清空,並導回到餐廳首頁而已~

// /controllers/user.js
logout: (req, res) => {
req.session.username = null;
res.redirect('/');
},

有了登入登出功能之後,接著來餐點、獎項和常見問題的新增、刪除、編輯功能,因為三者的做法都是類似的,這裡只會用餐點來做比較詳細的說明(但仍然不會很詳細 XD,只是紀錄知識點):

新增餐點

新增餐點頁面:

先在入口點 app.js 做出新增餐點頁面和新增餐點功能的 routing - middieware:

// app.js
app.get('/manage/menu/add', checkLogin, menuController.add)
app.post('/manage/menu/add', checkLogin, menuController.handleAdd);

開始在 menuController 來好 handleAdd 的 method,如果前端頁面的餐點名稱、價格、圖片 url 或順序沒填寫完整,就出現 flash 提示訊息:

// /controllers/menu.js
handleAdd: (req, res) => {
const { title, price, url, order } = req.body;
if (title === '' || price === '' || url === '' || order === '') {
  req.flash('errorMessage', '該填的沒填哦');
  return res.redirect('/manage/menu/add');
}

若都有填寫正確,就新增進 menus table,可以在 table 把順序的欄位設成 UNIQUE,這樣可以避免餐點的順序重複。

    ...
    Menu.create({
      title, price, url, order,
    })
      .then(() => {
        console.log('add successfully');
        return res.redirect('/manage/menu');
      })
      .catch((err) => {
        // order 重複會出現 1062 這個 errno,此時在頁面出現提示訊息
        if (err.original.errno === 1062) {
          req.flash('errorMessage', '順序不可以重複啦');
          return res.redirect('/manage/menu/add');
        }
        req.flash('errorMessage', err);
        return res.redirect('/manage/menu/add');
      });
    },

進階:前端的圖片網址欄位,還可以在前端串接 Imgur API ,這樣就可以在頁面上直接上傳圖片到 Imgur,Imgur 的 Response 回傳圖片網址後,再將網址放到 input 中,最後資料庫存的就只是這個線上圖片的網址:

串接 Imgur API

 // views/manage_menu_add.ejs
 <div class="manage">
   <h1>新增餐點</h1>

   <form method="POST" class="manage__edit" action="/manage/menu/add">
      <div class="image">
         <div class="image__block">
            <img id="image" src="/images/upload.png" alt="">
         </div>
         <input class="image__url" hidden type="text" name="url" value="" readonly />
         <input class="image__upload" type="file"/>
      </div>
       ...
// public/js/upload_picture.js
function init() {
  const urlDOM = document.querySelector('.image__url');
  const uploadDOM = document.querySelector('.image__upload');
  const imageDOM = document.getElementById('image');

  uploadDOM.addEventListener('change', (e) => {
    const formData = new FormData();
    formData.append('image', e.target.files[0]);
    fetch('https://api.imgur.com/3/image', {
      method: 'POST',
      headers: {
        Authorization: 'Client-ID 00000000',
      },
      body: formData,
    })
      .then(data => data.json())
      .then((data) => {
        urlDOM.value = data.data.link;
        imageDOM.src = data.data.link;
      });
  });
}

document.addEventListener('DOMContentLoaded', init);

管理餐點頁面

在資料庫有了餐點資料之後,管理餐點頁面就不再需要假資料了,可以在渲染管理餐點頁面的 method 上把資料庫中的資料傳過去:

// /controllers/menu.js
manageMenu: (req, res) => {
Menu.findAll({
  order: [['order']],
})
  .then(menus => res.render('manage_menu', { menus }))
  .catch((err) => {
    console.log(err);
    res.redirect('/');
  });
},

這時就可以發揮 ejs template 真正的效果啦,把 menus 資料都渲染在畫面上:

// /views/manage_menu.ejs
<body>
<%- include ('templates/nav_manage') %>

<div class="manage">

  <h1>菜單管理</h1>

  <div class="manage__title">
    <a href="/manage/menu/add">新增餐點</a>
  </div>

  <div class="manage__list menu">

    <% menus.forEach(menu => { %>
      <div class="item">
        <div class="picture">
          <img src="<%= menu.url %>" alt="圖片錯誤">
        </div>
        <div class="info">
          <div class="name">
            <p>餐點: <%= menu.title %></p>
          </div>

          <div class="price">
            <p>價格: <%= menu.price %> 元</p>
          </div>

          <div class="order">
            <p>順序: <%= menu.order %></p>
          </div>

          <div class="function">
            <div class="edit">
              <a href="/manage/menu/edit/<%= menu.id %>">編輯</a>
            </div>
            <div class="delete">
              <form action="/manage/menu/delete"  method="POST">
                <input name="id" hidden value="<%= menu.id %>" />
                <button type="submit">刪除</button>
              </form>
            </div>
          </div>
        </div>

      </div>         
    <% }) %>

  </div>
</div>
</body>

這樣就可以得到由後端資料渲染的管理餐點頁面惹:

我要點餐頁面

和管理餐點頁面一樣是把全部餐點資料從資料庫拿出來後再渲染到畫面上,這裡就不再示範一次步驟囉!

// /controllers/menu.js
menu: (req, res) => {
    Menu.findAll({
      order: [['order']],
    })
      .then(menus => res.render('menu', { menus }))
      .catch((err) => {
        console.log(err);
        return res.send('網頁維修中');
      });
  },

我要點餐頁面:

編輯餐點

編輯餐點的部份,一樣先在入口點 app.js 做出新增餐點頁面和編輯餐點功能的 routing - middieware:

// app.js
app.get('/manage/menu/edit/:id', checkLogin, menuController.edit, back)
app.post('/manage/menu/edit/:id', checkLogin, menuController.handleEdit);

和新增餐點不同的是,編輯餐點時,要在編輯餐點頁面上渲染原餐點的資料,做法是點擊每一個餐點的編輯按鈕時,拿到該餐點的 id,再由後端 controller 的 Sequelize 語法和 model 拿到該筆餐點的資料,把資料渲染到編輯餐點頁面上:

// /controllers/menu.js
edit: (req, res, next) => {
Menu.findByPk(req.params.id)
  .then(menu => res.render('manage_menu_edit', { menu }))
  .catch((err) => {
    console.log(err);
    return next();
  });
}

編輯餐點的做法和新增餐點是類似的,只是要先找到原來該筆餐點,並用 update 來更新資料而已:

// /controllers/menu.js
Menu.findByPk(req.params.id)
      .then((menu) => {
        menu
          .update({ title, price, url, order })
          .then(() => {
            console.log('update successfully');
            return res.redirect('/manage/menu');
          })
          .catch((err) => {
            if (err.original.errno === 1062) {
              req.flash('errorMessage', '順序不可以重複啦');
              return res.redirect(`/manage/menu/edit/${req.params.id}`);
            }
          });
      })
      .catch((err) => {
        console.log(err);
        return res.redirect(`/manage/menu/edit/${req.params.id}`);
      });

編輯餐點頁面

刪除餐點

先在入口點 app.js 做出新增餐點頁面和刪除餐點功能的 routing - middieware:

// app.js
router.post('/manage/menu/delete', checkLogin, menuController.delete);

接著就可以做出 method,一樣先找出該筆餐點之後,再執行 destroy 來刪除該筆資料,並重新導回到 /mange/menu 路由,也就是回到管理餐點的頁面:

// /controllers/menu.js
delete: (req, res) => {
Menu.findByPk(req.body.id)
  .then((menu) => {
    console.log('delete successfully');
    menu.destroy();
    return res.redirect('/manage/menu');
  })
  .catch((err) => {
    console.log(err);
    return res.redirect('/manage/menu');
  });
},

到這裡,餐點資料的渲染、新增、編輯、刪除功能都完成了,而常見問題和抽獎獎項也可以遵循一樣的步驟進行,第一次實作時我也被各種路由搞的暈頭轉向,但多做幾次以後就會比較熟練了,可以多試試看~

抽獎功能

method 除了可以用 render 方式把資料渲染到 ejs template 上,也可以在 Response 回傳 JSON 格式的資料,餐廳網站的抽獎功能,會使用前端 JavaScript 透過非同步 fetch 發送 Request 給後端,前端 JavaScript 拿到資料以後,再進行畫面的渲染。

抽獎頁面:

// /views/price_game.ejs
<body>
    <%- include ('templates/nav') %>

    <section class="games">

      <div class="container game">
        <h2>2020 夏日輕盈特賞! 抽獎活動辦法</h2>
        <div class="part">
          <h3>活動期間:</h3>
          <div>2020/11/01~2020/11/30</div>
        </div>
        <hr />

        <div class="part">
          <h3>活動說明:</h3>
          <div>
            今天老闆佛心來著決定給大家發獎勵,有看有機會,沒看只能幫QQ!只要在店內消費滿1000000元即有機會獲得頭獎日本東京來回雙人遊!
          </div>
        </div>

        <hr />

        <div class="part">
          <h3>獎&emsp;&emsp;品:</h3>
          <div>
            <% prizes.forEach(prize => { %>
            <p>❤ <%= `${prize.title}:${prize.content}` %></p>
            <% }) %>
          </div>
        </div>

        <hr />
        <button class="play__game" onclick="this.disabled=true">
          我要抽獎
        </button>
      </div>

      <div class="container prize__game hide">
        <h2 class="name"></h2>
        <p class="content"></p>
        <button class="reload">再抽一次</button>
      </div>
    </section>

    <%- include ('templates/footer') %>
</body>

抽獎頁面前端 JavaScript

// /public/js/prize.js
function init() {
  const games = document.querySelector('.games');
  const prizeName = document.querySelector('.name');
  const prizeContent = document.querySelector('.content');
  const game = document.querySelector('.game');
  const playPage = document.querySelector('.prize__game');

  function reload() {
    window.location.reload(true);
  }

  function error(err) {
    alert('系統不穩定,請再試一次');
    console.log(err);
    reload();
  }

  function render(prize) {
    games.style.background = `url(${prize.url}) center / cover no-repeat`;
    game.classList.add('hide');
    playPage.classList.remove('hide');
    prizeName.innerHTML = prize.title;
    prizeContent.innerHTML = prize.content;
  }

  function getAPI() {
    return fetch('/getPrize', {
      method: 'GET',
    })
      .then(res => res.json())
      .then(data => render(data))
      .catch(err => error(err));
  }

  document.querySelector('.play__game').addEventListener('click', getAPI);
  document.querySelector('.reload').addEventListener('click', reload);
}
document.addEventListener('DOMContentLoaded', () => {
  init();
});

購物車功能

加入購物車功能主要是用前端 JavaScript 的 localStorage 來處理,而購物車的頁面,則是透過前端 JavaScript 利用非同步發送 Request,從後端拿到餐點資料後,再渲染出購物車的 modal,這邊前端部份僅附上原始碼供參考:

而後端部份,就是收到前端傳過來的購物車資料後,去資料庫查找出對應的餐點並回傳給前端:

// /controllers/order.js
getCart: async (req, res) => {
    const clientResult = req.body;
    const resultArr = [];
    for await (let product of clientResult) {
      let item = {};
      const result = await Menu.findOne({
        where: {
          id: product.id,
        },
      });
      item.id = result.id;
      item.title = result.title;
      item.price = result.price;
      item.quantity = product.quantity;
      resultArr.push(item);
    }
    res.status(200).json(resultArr);
  },

拆分路由部份

到這裡餐廳網站的功能部份也都完成啦,如果讓路由 routing 部份從 app.js 獨立出來,可以讓整個 Express 的專案架構更清晰:

// app.js
const express = require('express');
const session = require('express-session');
// 載入路由部份
const routes = require('./routes');
const flash = require('connect-flash');
const app = express();
const port = process.env.PORT || 5556;

app.set('view engine', 'ejs');
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(`${__dirname}/public`));
app.use(flash());
app.use(
  session({
    secret: 'keyboard cat',
    resave: false,
    saveUninitialized: true,
  }),
);

app.use((req, res, next) => {
  res.locals.errorMessage = req.flash('errorMessage');
  res.locals.username = req.session.username;
  next();
});

app.listen(port, () => {
  console.log(`express restaurant app listening on port ${port}`);
});

app.use('/', routes);

拆分出來完整的 routing 檔案會像這樣:


// /routes/index.js
const express = require('express')
const router = express.Router();

const userController = require('../controllers/user');
const prizeController = require('../controllers/prize');
const questionController = require('../controllers/question');
const menuController = require('../controllers/menu');
const orderController = require('../controllers/order');

function back(req, res) {
  res.redirect('back');
}

function checkLogin(req, res, next) {
  if (!req.session.username) return res.redirect('/login');
  next();
}

router.get('/register', userController.register)
router.post('/register', userController.handleRegister, back);

router.get('/login', userController.login)
router.post('/login', userController.handleLogin, back);
router.get('/logout', userController.logout);

router.get('/', userController.home);
router.get('/menu', menuController.menu);
router.get('/question', questionController.question);
router.get('/prize', prizeController.prize);

router.get('/manage', checkLogin, userController.manage);
router.get('/manage/menu', checkLogin, menuController.manageMenu);
router.get('/manage/question', checkLogin, questionController.manageQuestion);
router.get('/manage/prize', checkLogin, prizeController.managePrize);
router.get('/manage/order', checkLogin, orderController.manageOrder);

router.get('/manage/prize/add', checkLogin, prizeController.add)
router.post('/manage/prize/add', checkLogin, prizeController.handleAdd);
router.get('/manage/prize/edit/:id', checkLogin, prizeController.edit, back)
router.post('/manage/prize/edit/:id', checkLogin, prizeController.handleEdit);
router.post('/manage/prize/delete', checkLogin, prizeController.delete);

router.get('/manage/question/add', checkLogin, questionController.add)
router.post('/manage/question/add', checkLogin, questionController.handleAdd);
router.get('/manage/question/edit/:id', checkLogin, questionController.edit, back)
router.post('/manage/question/edit/:id', checkLogin, questionController.handleEdit);
router.post('/manage/question/delete', checkLogin, questionController.delete);

router.get('/manage/menu/add', checkLogin, menuController.add)
router.post('/manage/menu/add', checkLogin, menuController.handleAdd);
router.get('/manage/menu/edit/:id', checkLogin, menuController.edit, back)
router.post('/manage/menu/edit/:id', checkLogin, menuController.handleEdit);
router.post('/manage/menu/delete', checkLogin, menuController.delete);

router.get('/getPrize', prizeController.play);
router.post('/cart', orderController.getCart);

module.exports = router

Heroku 佈署

完成好的 Express 餐廳網站專案,可以用 Heroku 搭配 ClearDB 資料庫快速佈署起來,可以先試著按著官方文件操作:

簡易佈署流程:

  1. 利用 npm 安裝 Heroku
  2. package.json 設定 deploy 引擎及 node 版本

     "engines": {
     "node": "12.x"
     },
    
  3. 為了讓 heroku 在佈署的時候先執行 Sequelize-cli 的 migration 來建立資料庫。先在 package.json 中 scripts 的 start 指令,加入 migrate 的執行

     "scripts": {
         "migrate": "npx sequelize-cli db:migrate",
         "start": "npm run migrate && node app.js",
         "localStart": "node app.js",
         "test": "echo \"Error: no test specified\" && exit 1"
       },
    
  4. 在入口點 app.js 將 PORT 設定為環境變數

     const PORT = process.env.PORT || 5566;
    
  5. 初始化 git 專案並把 node_modules 加入 .gitignore
  6. 建立 Heroku 專案:heroku create
  7. 上傳並佈署 Heroku 專案:git push heroku master
  8. 安裝 clearDB 插件:heroku addons:create cleardb:ignite
  9. 在 config.json 的 Production 中設定環境變數為 CLEARDB_DATABASE_URL

     "production": {
     "username": "test",
     "password": "test",
     "database": "test",
     "host": "localhost",
     "dialect": "mysql",
     "use_env_variable": "CLEARDB_DATABASE_URL" 
    }
    
  10. 再次上傳並佈署 Heroku 專案:git push heroku master,完成佈署。

  11. 可以在 Heroku 該專案後台的 Settings 的 Config Vars 中取得 ClearDB 的 host, 帳號及密碼,並用自己熟悉的資料庫管理工具來直接管理資料庫(這裡偷偷推薦 Dbninja

完整程式碼及實際網站參考

Just A Bite 餐廳網站
完整程式碼


#Express #Sequelize #node.js #javascript #程式導師計畫







Related Posts

深入探討 JavaScript 中的參數傳遞:call by value 還是 reference?

深入探討 JavaScript 中的參數傳遞:call by value 還是 reference?

[Week 6] CSS:其他整理

[Week 6] CSS:其他整理

6. 選擇適合的部署方式

6. 選擇適合的部署方式


Comments