基於nodeJS從0到1實現一個CMS全棧項目(中)(含源碼)

  • 2019 年 10 月 10 日
  • 筆記

今天給大家介紹的主要是我們全棧CMS系統的後台部分,由於後台部分涉及的點比較多,我會拆解成幾部分來講解,如果對項目背景和技術棧不太了解,可以查看我的上一篇文章

基於nodeJS從0到1實現一個CMS全棧項目(上)

這篇文章除了會涉及node的知識,還會涉及到redis(一個高性能的key-value數據庫),前端領域的javascript大部分高級技巧以及ES6語法,所以在學習之前希望大家對其有所了解。

摘要

本文主要介紹CMS服務端部分的實現,具體包括如下內容:

  • 如何使用babel7讓node支持更多es6+語法以及nodemon實現項目文件熱更新和自動重啟
  • node項目的目錄結構設計和思想
  • 如何基於ioredis和json-schema自己實現一個類schema的基礎庫
  • 基於koa-session封裝一個sessionStore庫
  • 基於koa/multer封裝文件處理的工具類
  • 實現自定義的koa中間鍵和restful API
  • 模版引擎pug的基本使用及技巧

由於每一個技術點實現的細節很多,建議先學習相關內容,如果不懂的可以和我交流。

正文

一. 如何使用babel7讓node支持更多es6+語法以及nodemon實現項目文件熱更新和自動重啟

最新的node雖然已經支持大部分es6+語法,但是對於import,export這些模塊化導入導出的API還沒有徹底支持,所以我們可以通過babel去編譯支持,如果你習慣使用commonjs的方式,也可以直接使用。這裡我直接寫出我的配置:

  1. package.json安裝babel模塊和nodemon熱重啟
"devDependencies": {      "@babel/cli": "^7.5.5",      "@babel/core": "^7.5.5",      "@babel/node": "^7.5.5",      "@babel/plugin-proposal-class-properties": "^7.5.5",      "@babel/plugin-proposal-decorators": "^7.4.4",      "@babel/preset-env": "^7.5.5",      "nodemon": "^1.19.1"    },
  1. 配置.babelrc文件,讓node支持import,export,class以及裝飾器:
// .babelrc  {      "presets": [        [          "@babel/preset-env",          {            "targets": {              "node": "current"            }          }  ]      ],      "plugins": [        ["@babel/plugin-proposal-decorators", { "legacy": true }],        ["@babel/plugin-proposal-class-properties", { "loose" : true }]      ]    }
  1. 配置啟動腳本。為了使用npm的方式啟動項目,我們在package.json里配置如下腳本:
"scripts": {      "start": "export NODE_ENV=development && nodemon -w src --exec "babel-node src"",      "build": "babel src --out-dir dist",      "run-build": "node dist",      "test": "echo "Error: no test specified" && exit 1"    },  複製代碼

有關babel7和nodemon以及npm的一些配置問題和使用方式,不過有不懂的可以在文章末尾和我交流。這裡提供幾個學習鏈接:

  • babel7文檔教程
  • nodemon官方文檔
  • 用 webpack 4.0 擼單頁/多頁腳手架 (jquery, react, vue, typescript)

至此,我們node項目的基礎設施基本搭建完成了,接下來我們繼續深入服務端設計底層。

二. node項目的目錄結構設計和思想

首先來看看我們完成後的目錄設計:

項目參考了很多經典資料和MDN的文檔,採用經典的MVC模式,為了方便理解,筆者特意做了一個大致的導圖:

這種模式用於應用程序的分層開發,方便後期的管理和擴展,並提供了清晰的設計架構。

  • Model層我們管理數據對象,它也可以帶有邏輯,在數據變化時更新控制器。
  • View層主要用來展示數據的視圖。
  • Controller控制器作用於模型和視圖上。它控制數據流向模型對象,並在數據變化時更新視圖,使視圖與模型分離開。

三. 基於ioredis和json-schema自己實現一個類schema的基礎庫

在項目開發前,我們需要根據業務結構和內容設計數據模型,數據庫部分我這裡採用的是redis+json-schema,本來想使用mongodb來實現主數據的存儲,但是考慮到自己對新方案的研究和想自己通過二次封裝redis實現類mongoose的客戶端管理框架,所以這裡會採用此方案,關於mongoDB的實現,我之前也有項目案例,感興趣可以一起交流優化。

我們來先看看CMS設計的視圖和內容,我們分管理端和客戶端,管理端主要的模塊有:

  1. 登錄模塊

2. 首頁配置管理模塊

配置頁主要包括header頭部,banner位,bannerSider側邊欄和文章讚賞設置,我們對對它做一個單獨的config數據庫。

3. 文章管理模塊

這裡我們需要對文章數據進行存儲,包括文章分類,文章首圖,文章內容等信息,如下:

4. 圖片管理

圖片管理主要是方便博主管理圖片信息,定位圖片的來源,方便後期做埋點跟蹤。

  1. 網站統計

網站統計只是一個雛形,博主可以根據自己需求做統計分析,提高更大的自定義。

  1. 管理員模塊

這裡用來管理系統的管理員,可以分配管理員權限等。關於權限的設計,可以有更複雜的模式,後面有需要也可以相互交流。

根據以上的展示,我們大致知道了我們需要設計哪些數據庫模型,接下來我先帶大家封裝redis-schema,也是我們用到的數據庫的底層工具:

// lib/schema.js  import { validate } from 'jsonschema'  import Redis from 'ioredis'    const redis = new Redis()    class RedisSchema {      constructor(schemaName, schema) {          this.schemaName = schemaName          this.schema = schema          this.redis = redis      }        validate(value, schema, cb) {          const { valid, errors } = validate(value, schema);          if(valid) {              return cb()          }else {              return errors.map(item => item.stack)          }      }        get() {          return this.redis.get(this.schemaName)      }        // 獲取整個hash對象      hgetall() {          return this.redis.hgetall(this.schemaName)      }        // 獲取指定hash對象的屬性值      hget(key) {          return this.redis.hget(this.schemaName, key)      }        //  通過索引獲取列表中的元素      lindex(index) {          return this.redis.lindex(this.schemaName, index)      }        //  獲取列表中指定範圍的元素      lrange(start, end) {          return this.redis.lrange(this.schemaName, start, end)      }        // 獲取列表的長度      llen() {          return this.redis.llen(this.schemaName)      }        // 檢測某個schemaName是否存在      exists() {          return this.redis.exists(this.schemaName)      }        // 給某個schemaName設置過期時間,單位為秒      expire(time) {          return this.redis.expire(this.schemaName, time)      }        // 移除某個schemaName的過期時間      persist() {          return this.redis.persist(this.schemaName)      }        // 修改schemaName名      rename(new_schemaName) {          return this.redis.rename(this.schemaName, new_schemaName)      }          set(value, time) {          return this.validate(value, this.schema, () => {              if(time) {                  return this.redis.set(this.schemaName, value, "EX", time)              }else {                  return this.redis.set(this.schemaName, value)              }          })      }        // 將某個schema的值自增指定數量的值      incrby(num) {          return this.redis.incrby(this.schemaName, num)      }        // 將某個schema的值自增指定數量的值      decrby(num) {          return this.redis.decrby(this.schemaName, num)      }        hmset(key, value) {          if(key) {              if(this.schema.properties){                  return this.validate(value, this.schema.properties[key], () => {                      return this.redis.hmset(this.schemaName, key, JSON.stringify(value))                  })              }else {                  return this.validate(value, this.schema.patternProperties["^[a-z0-9]+$"], () => {                      return this.redis.hmset(this.schemaName, key, JSON.stringify(value))                  })              }            }else {              return this.validate(value, this.schema, () => {                  // 將第一層鍵值json化,以便redis能正確存儲鍵值為引用類型的值                  for(key in value) {                      let v = value[key];                      value[key] = JSON.stringify(v);                  }                  return this.redis.hmset(this.schemaName, value)              })          }      }        hincrby(key, num) {          return this.redis.hincrby(this.schemaName, key, num)      }        lpush(value) {          return this.validate(value, this.schema, () => {              return this.redis.lpush(this.schemaName, JSON.stringify(value))          })      }        lset(index, value) {          return this.redis.lset(this.schemaName, index, JSON.stringify(value))      }        lrem(count, value) {          return this.redis.lrem(this.schemaName, count, value)      }        del() {          return this.redis.del(this.schemaName)      }        hdel(key) {          return this.redis.hdel(this.schemaName, key)      }  }    export default RedisSchema

這個筆者自己封裝的庫還有很多可擴展的地方,比如增加類事物處理,保存前攔截器等等,我會在第二版改進,這裡只供參考。關於json-schema更多的知識,如有不懂,可以在我們的交流區溝通學習。我們定義一個管理員的schema:

/db/schema/admin.js  import RedisSchema from '../../lib/schema'    // 存放管理員數據  const adminSchema = new RedisSchema('admin', {      id: "/admin",      type: "object",      properties: {          username: {type: "string"},          pwd: {type: "string"},          role: {type: "number"}   // 0 超級管理員 1 普通管理員        }    })    export default adminSchema

由上可以知道,管理員實體包含username用戶名,密碼pwd,角色role,對於其他的數據庫設計,也可以參考此方式。

四. 基於koa-session封裝一個sessionStore庫

由於session的知識網上很多資料,這裡就不耽誤時間了,這裡列出我的方案:

function getSession(sid) {      return `session:${sid}`  }    class sessionStore {      constructor (client) {          this.client = client      }        async get (sid) {          let id = getSession(sid)          let result = await this.client.get(id)          if (!result) {              return null          } else {              try{                  return JSON.parse(result)              }catch (err) {                  console.error(err)              }          }      }        async set (sid, value, ttl) {          let id = getSession(sid)            try {              let sessStr = JSON.stringify(value)              if(ttl && typeof ttl === 'number') {                  await this.client.set(id, sessStr, "EX", ttl)              } else {                  await this.client.set(id, sessStr)              }          } catch (err) {              console.log('session-store', err)          }      }        async destroy (sid) {          let id = getSession(sid)          await this.client.del(id)      }  }    module.exports = sessionStore

這裡主要實現了session的get,set,del操作,我們主要用來處理用戶的登錄信息。

五. 基於koa/multer封裝文件處理的工具類

文件上傳的方案我是在github上看的koa/multer,基於它封裝文件上傳的庫,但凡涉及到文件上傳的操作都會使用它。

import multer from '@koa/multer'  import { resolve } from 'path'  import fs from 'fs'    const rootImages = resolve(__dirname, '../../public/uploads')  //上傳文件存放路徑、及文件命名  const storage = multer.diskStorage({      destination: function (req, file, cb) {          cb(null, rootImages)      },      filename: function (req, file, cb) {          let [name, type] = file.originalname.split('.');          cb(null, `${name}_${Date.now().toString(16)}.${type}`)      }  })  //文件上傳限制  const limits = {      fields: 10,//非文件字段的數量      fileSize: 1024 * 1024 * 2,//文件大小 單位 b      files: 1//文件數量  }    export const upload = multer({storage,limits})    // 刪除文件  export const delFile = (path) => {      return new Promise((resolve, reject) => {          fs.unlink(path, (err) => {              if(err) {                  reject(err)              }else {                  resolve(null)              }          })      })  }    // 刪除文件夾  export function deleteFolder(path) {      var files = [];      if(fs.existsSync(path)) {          files = fs.readdirSync(path);          files.forEach(function(file,index){              var curPath = path + "/" + file;              if(fs.statSync(curPath).isDirectory()) { // recurse                  deleteFolder(curPath);              } else { // delete file                  fs.unlinkSync(curPath);              }          });          fs.rmdirSync(path);      }  }    export function writeFile(path, data, encode) {      return new Promise((resolve, reject) => {          fs.writeFile(path, data, encode, (err) => {              if(err) {                  reject(err)              }else {                  resolve(null)              }          })      })  }

這套方案包含了上傳文件,刪除文件,刪除目錄的工具方法,可以拿來當輪子使用到其他項目,也可以基於我的輪子做二次擴展。

關於實現自定義的koa中間鍵和restful API和模版引擎pug的基本使用及技巧部分,由於時間原因,我會在明天繼續更新,以上部分如有不懂的,可以和筆者交流學習。