用hapi.js mysql和nuxt.js(vue ssr)開發仿簡書的博客項目

前言:

預覽:

開始:

  1. npm i
  2. 把mysql配置好
  3. npm run server or npm run dev

實現功能:

  • 用戶: 登陸、註冊、用戶資料修改,詳情頁面,相似於簡書的文章數量、總字數、收穫的喜歡總數,文章刪除。

用戶頁面.jpg

  • 文章:文章詳情頁面,查看,評論,點贊和踩,文章閱讀次數統計

文章詳情.jpg

  • 文章: 全部文章,支持分頁和按關鍵詞、時間查找

全部文章.jpg

  • 文章書寫:支持markdown和圖片拖拽上傳

image

  • 首頁: 文章推薦,做者推薦,首頁輪播,頂部搜索文章和用戶

首頁.jpg

  • ssr 效果預覽:

相似於知乎的
ssr.jpgcss

  • seo 效果:

待補充html

1 技術棧:

  • 前端:axios、element-ui、nuxt.js、 ts
  • 後端:node.js、hapi.js、sequelize(orm)、 hapi-auth(token)、 hapi-swagger(在線api文檔)、hapi-pagination(分頁)、joi(前端請求數據檢驗相似於element的表單校驗)、 mysql 和其餘插件

2 技術細節介紹:

說明: 本文主要側重後端,最後的效果相似於我司後端前端

目錄結構:

├── assets // 靜態資源,css, 圖片等
├── client // 客戶端目錄,axios請求函數和其餘輔助函數
├── components // vue組件目錄
├── config // 默認設置
├── layouts // nuxt視圖
├── middleware // nuxt 中間件
├── migrations // orm 數據遷移
├── models // orm 數據模型 
├── nuxt.config.js 
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── pages // nuxt
├── plugins // hapi插件和nuxt插件
├── routes // hapi路由
├── seeders // 種子數據
├── server // app.js
├── static // 靜態資源
├── store // nuxt
├── tsconfig.json 
├── uploads // 文件上傳目標目錄
└── utils // 輔助函數

前言:爲何是hapi.js ?

hapi官方文檔已經說了不少了(expresstohapi),這裏最吸引個人是,不用安裝不少的插件(expres的話有不少的xx-parse插件...),就能知足個人需求,並且hapi已經應用於商用了。vue

注意點:

個人這些代碼,在我目前的package.json的版本是能正常運行的,hapi版本大版本有時候會出現不兼容的,不一樣版本的hapi對應着不一樣的插件版本,因此須要和個人版本保持一致,我還遇到過nuxt.js v2.9運行加入ts出現不識別@component的狀況,安裝2.8.x版本就沒有問題。node

2.1 Sequelize建模

開發後臺第一個想到的是創建數據模型(建表),默認你已經安裝好了mysql
以前我本身用數據庫,不知道有orm這個工具的時候,會選擇本身用navicat這樣的圖形化工具建表或者直接用sql語句建表。這樣作有幾個缺點:mysql

  1. 對數據庫的操做記錄不明確,我新建一個表或者新增字段,我後悔了,刪掉,我又後悔了,orm的數據遷移就能夠用來作這些事情相似於git。
  2. 遷移新環境,用sql操做很麻煩,直接執行orm的命令自動建表。
  3. 數據模型,以前後臺程序與mysql聯繫的時候,僅僅在創建了鏈接池,數據的關係,表結構這些咱們其實並不知道。
  4. 執行增刪改查代碼更簡潔清晰
  5. 其餘

注意:用orm在執行sql操做時,至關於咱們用jquery執行dom操做,api簡單了,但仍是須要對原來的有點了解jquery

sequelize

sequelize就是node.js的promise orm工具,同時也支持其餘數據庫.ios

使用

  1. 安裝插件:
npm i sequelize-cli -D
npm i sequelize
npm i mysql2
  1. sequelize init

經過 sequelize-cli 初始化 sequelize,咱們將獲得一個好用的初始化結構:git

// 能夠安裝npx
node_modules/.bin/sequelize init
├── config                       # 項目配置目錄
|   ├── config.json              # 數據庫鏈接的配置
├── models                       # 數據庫 model
|   ├── index.js                 # 數據庫鏈接的樣板代碼
├── migrations                   # 數據遷移的目錄
├── seeders                      # 數據填充的目錄

config/config.jsongithub

默認生成文件爲一個 config.json 文件,文件裏配置了開發、測試、生產三個默認的樣板環境,咱們能夠按需再增長更多的環境配置。這裏我用config.js替代config.json,這樣配置更加靈活
修改後的 config/config.js 以下,僅預留了 development(開發) 與 production(生產) 兩個環境,開發環境與生產環境的配置參數能夠分離在 .env 和 .env.prod 兩個不一樣的文件裏,經過環境變量參數 process.env.NODE_ENV 來動態區分。

// config.js
if (process.env.NODE_ENV === 'production') {
  require('env2')('./.env.prod')
} else {
  require('env2')('./.env.dev')
}

const { env } = process
module.exports = {
  'development': {
    'username': env.MYSQL_USERNAME,
    'password': env.MYSQL_PASSWORD,
    'database': env.MYSQL_DB_NAME,
    'host': env.MYSQL_HOST,
    'port': env.MYSQL_PORT,
    dialect: 'mysql',
    logging: false, // mysql 執行日誌
    timezone: '+08:00'
    // "operatorsAliases": false,  // 此參數爲自行追加,解決高版本 sequelize 鏈接警告
  },
  'production': {
    'username': env.MYSQL_USERNAME,
    'password': env.MYSQL_PASSWORD,
    'database': env.MYSQL_DB_NAME,
    'host': env.MYSQL_HOST,
    'port': env.MYSQL_PORT,
    dialect: 'mysql',
    timezone: '+08:00'
    // "operatorsAliases": false, // 此參數爲自行追加,解決高版本 sequelize 鏈接警告
  }
}

.env.dev

# 服務的啓動名字和端口,但也能夠缺省不填值,默認值的填寫只是必定程度減小起始數據配置工做
HOST = 127.0.0.1
PORT = 80
#  端口最好就爲80,否則axios url要改成絕對地址
# MySQL 數據庫連接配置
MYSQL_HOST = 111.111.111.111
MYSQL_PORT = 3306
MYSQL_DB_NAME = 數據庫名
MYSQL_USERNAME = 數據庫用戶名
MYSQL_PASSWORD = 數據庫密碼
JWT_SECRET = token密鑰
  1. 建立數據庫
npx sequelize db:create
  1. 建立遷移文件
npx migration:create --name user

在 migrations 的目錄中,會新增出一個 時間戳-user.js 的遷移文件,自動生成的文件裏,包涵有 up 與 down 兩個空函數, up 用於定義表結構正向改變的細節,down 則用於定義表結構的回退邏輯。好比 up 中有 createTable 的建錶行爲,則 down 中配套有一個對應的 dropTable 刪除錶行爲。至關因而一條操做記錄記錄。修改後的用戶遷移文件以下:

'use strict'
module.exports = {
  up: (queryInterface, Sequelize) => queryInterface.createTable(
    'user',
    {
      uid: {
        type: Sequelize.UUID,
        primaryKey: true
      },
      nickname: {
        type: Sequelize.STRING,
        allowNull: false,
        unique: true
      },
      avatar: Sequelize.STRING,
      description: Sequelize.STRING,
      username: {
        type: Sequelize.STRING,
        allowNull: false,
        unique: true
      },
      password: {
        type: Sequelize.STRING,
        allowNull: false
      },
      created_time: Sequelize.DATE,
      updated_time: Sequelize.DATE
    },
    {
      charset: 'utf8'
    }
  ),

  down: queryInterface => queryInterface.dropTable('user')
}
  1. 執行遷移
npx sequelize db:migrate

sequelize db:migrate 的命令,能夠最終幫助咱們將 migrations 目錄下的遷移行爲定義,按時間戳的順序,逐個地執行遷移描述,最終完成數據庫表結構的自動化建立。而且,在數據庫中會默認建立一個名爲 SequelizeMeta 的表,用於記錄在當前數據庫上所運行的遷移歷史版本。已經執行過的不會再次執行,能夠執行sequelize db:migrate:undo執行上個遷移文件的down命令。

  1. 種子數據

執行

sequelize seed:create --name init-user

相似的在seeders目錄下生成一份文件 時間戳-init-user.js
修改後

'use strict'
const uuid = require('uuid')
const timeStamp = {
  created_time: new Date(),
  updated_time: new Date()
}
const users = []
for (let i = 1; i < 5; i++) {
  users.push(
    {
      uid: uuid(), username: 'zlj' + i, password: '123', nickname: '火鍋' + 1, ...timeStamp
    }
  )
}
module.exports = {
  up: queryInterface => queryInterface.bulkInsert('user', users, { charset: 'utf-8' }),
  down: (queryInterface, Sequelize) => {
    const { Op } = Sequelize
    return queryInterface.bulkDelete('user', { uid: { [Op.in]: users.map(v => v.uid) } }, {})
  }
}

執行填充命令

sequelize db:seed:all

查看數據庫user表就多了一些記錄,其餘的操做相似於遷移,更多的操做能夠看文檔
7 定義模型
user表 models/user.js

const moment = require('moment')
module.exports = (sequelize, DataTypes) => sequelize.define(
  'user',
  {
    uid: {
      type: DataTypes.UUID,
      primaryKey: true
    },
    avatar: DataTypes.STRING,
    description: DataTypes.STRING,
    nickname: {
      type: DataTypes.STRING,
      unique: true,
      allowNull: false
    },
    username: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true
    },
    password: {
      type: DataTypes.STRING,
      allowNull: false
    },
    created_time: {
      type: DataTypes.DATE,
      get () {
        return moment(this.getDataValue('created_time')).format('YYYY-MM-DD HH:mm:ss')
      }
    },
    updated_time: {
      type: DataTypes.DATE,
      get () {
        return moment(this.getDataValue('updated_time')).format('YYYY-MM-DD HH:mm:ss')
      }
    }
  },
  {
    tableName: 'user'
  }
)
  1. 實例化seqlize

modes/index.js

'use strict'
const fs = require('fs')
const path = require('path')
const uuid = require('uuid')
const Sequelize = require('sequelize')
const basename = path.basename(__filename) // eslint-disable-line
const configs = require(path.join(__dirname, '../config/config.js'))
const db = {}
const env = process.env.NODE_ENV || 'development'
const config = {
  ...configs[env],
  define: {
    underscored: true,
    timestamps: true,
    updatedAt: 'updated_time',
    createdAt: 'created_time',
    hooks: {
      beforeCreate (model) {
        model.uid = uuid()
      }
    }
  }
}
let sequelize
if (config.use_env_variable) {
  sequelize = new Sequelize(process.env[config.use_env_variable], config)
} else {
  sequelize = new Sequelize(config.database, config.username, config.password, config)
}
fs
  .readdirSync(__dirname)
  .filter((file) => {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js')
  })
  .forEach((file) => {
    const model = sequelize.import(path.join(__dirname, file))
    db[model.name] = model
  })
Object.keys(db).forEach((modelName) => {
  if (db[modelName].associate) {
    db[modelName].associate(db)
  }
})
db.sequelize = sequelize
db.Sequelize = Sequelize
// 外鍵關聯關係
// 假設你全部表創建好了
db.user.hasMany(db.article, { foreignKey: 'uid' })
db.article.belongsTo(db.user, { foreignKey: 'author' })
db.user.hasMany(db.comment, { foreignKey: 'uid' })
db.comment.belongsTo(db.user, { foreignKey: 'author' })
db.user.hasMany(db.article_like, { foreignKey: 'uid' })
db.article_like.belongsTo(db.user, { foreignKey: 'author' })
db.article.hasMany(db.comment)
db.comment.belongsTo(db.article)
db.article.hasMany(db.article_like)
db.article_like.belongsTo(db.article)
module.exports = db
  1. 本項目用到的功能

    多表查詢、單表增刪改查、模型統一配置、遷移和種子填充、事務(刪除文章的時候,把文章相關的數據:評論,閱讀,點贊數據也一塊兒刪了。)等。

2.2 Joi 請求參數校驗

joi能夠對請求參數進行校驗

使用:

  1. 安裝
# 安裝適配 hapi v16 的 joi 插件
npm i joi@14
  1. 使用見2.3 config.validate,更多參考官方文檔

2.3 用hapi 寫接口

post: 登陸接口:
routes/user.js

const models = require('../models')
const Joi = require('@hapi/joi')
{
    method: 'POST',
    path: '/api/user/login',
    handler: async (request, h) => {
      const res = await models.user.findAll({
        attributes: {
          exclude: ['password', 'created_time', 'updated_time']
        },
        where: {
          username: request.payload.username,
          // 通常密碼存庫都會加密的,md5等
          password: request.payload.password
        }
      })
      const data = res[0]
      if (res.length > 0) {
        return h.response({
          code: 0,
          message: '登陸成功!',
          data: {
            // 寫入token
            token: generateJWT(data.uid),
            ...data.dataValues
          }
        })
      } else {
        return h.response({
          code: 10,
          message: '用戶名或密碼錯誤'
        })
      }
    },
    config: {
      auth: false,
      tags: ['api', 'user'],
      description: '用戶登陸',
      validate: {
        payload: {
          username: Joi.string().required(),
          password: Joi.string().required()
        }
      }
    }
  },

2.4 接口文檔swagger

  1. 安裝:
npm i hapi-swagger@10
npm i inert@5
npm i vision@5
npm i package@1
  1. 使用
├── plugins                       # hapi 插件配置
|   ├── hapi-swagger.js

hapi-swagger.js

// plugins/hapi-swagger.js
const inert = require('@hapi/inert')
const vision = require('@hapi/vision')
const package = require('package')
const hapiSwagger = require('hapi-swagger')
module.exports = [
  inert,
  vision,
  {
    plugin: hapiSwagger,
    options: {
      documentationPath: '/docs',
      info: {
        title: 'my-blog 接口 文檔',
        version: package.version
      },
      // 定義接口以 tags 屬性定義爲分組
      grouping: 'tags',
      tags: [
        { name: 'user', description: '用戶接口' },
        { name: 'article', description: '文章接口' }
      ]
    }
  }
]

server/index.js

const pluginHapiSwagger = require('../plugins/hapi-swagger')
// 註冊插件
...
 await server.register([
    // 爲系統使用 hapi-swagger
    ...pluginHapiSwagger
  ]
...

打開你的dev.host:dev.port/docs
能夠查看我線上的

2.5 token認證hapi-auth-jwt2

cookie hapi已經幫你解析好了,文件上傳也是

  1. 安裝:

npm i hapi-auth-jwt2@8

  1. 配置:

文檔

├── plugins                       # hapi 插件配置
│ ├── hapi-auth-jwt2.js           # jwt 配置插件

hapi-auth-jwt2.js

const validate = (decoded) => {
  // eslint disable
  // decoded 爲 JWT payload 被解碼後的數據
  const { exp } = decoded
  if (new Date(exp * 1000) < new Date()) {
    const response = {
      code: 4,
      message: '登陸過時',
      data: '登陸過時'
    }
    return { isValid: true, response }
  }
  return { isValid: true }
}
module.exports = (server) => {
  server.auth.strategy('jwt', 'jwt', {
    // 須要自行在 config/index.js 中添加 jwtSecret 的配置,而且經過 process.env.JWT_SECRET 來進行 .git 版本庫外的管理。
    key: process.env.JWT_SECRET,
    validate,
    verifyOptions: {
      ignoreExpiration: true
    }
  })
  server.auth.default('jwt')
}
  1. 註冊插件

server/index.js

const hapiAuthJWT2 = require('hapi-auth-jwt2')
...
await server.register(hapiAuthJWT2)
...
  1. 效果:

默認狀況下全部的接口都須要token認證的
能夠將某個接口(好比登陸接口)config.auth = false不開啓
回到上面的登陸接口,用戶名和密碼檢驗成功就生成token

const generateJWT = (uid) => {
  const payload = {
    userId: uid,
    exp: Math.floor(new Date().getTime() / 1000) + 24 * 60 * 60
  }
  return JWT.sign(payload, process.env.JWT_SECRET)
}
handler () {
      const res = await models.user.findAll({
        attributes: {
          exclude: ['password', 'created_time', 'updated_time']
        },
        where: {
          username: request.payload.username,
          password: request.payload.password
        }
      })
      const data = res[0]
      if (res.length > 0) {
        return h.response({
          code: 0,
          message: '登陸成功!',
          data: {
            token: generateJWT(data.uid),
            ...data.dataValues
          }
        })
      } else {
        return h.response({
          code: 10,
          message: '用戶名或密碼錯誤'
        })
      }
    }

前端拿到toke塞在頭部就行了
client/api/index.ts

request.interceptors.request.use((config: AxiosRequestConfig): AxiosRequestConfig => {
  const token = getToken()
  if (token) { config.headers.authorization = token }
  return config
})
  1. 請求頭增長Joi校驗
const jwtHeaderDefine = {
  headers: Joi.object({
    authorization: Joi.string().required()
  }).unknown()
}
// 某個接口
...
validate: {
        ...jwtHeaderDefine,
        params: {
          uid: Joi.string().required()
        }
      }
...

能夠從swagger在線文檔中文看出變化

2.6 加入分頁hapi-pagination

  1. 安裝

npm i hapi-pagination@3

  1. 配置

plugins/hapi-pagination.js

const hapiPagination = require('hapi-pagination')
const options = {
  query: {
    page: {
      name: 'the_page' // The page parameter will now be called the_page
    },
    limit: {
      name: 'per_page', // The limit will now be called per_page
      default: 10 // The default value will be 10
    }
  },
  meta: {
    location: 'body', // The metadata will be put in the response body
    name: 'metadata', // The meta object will be called metadata
    count: {
      active: true,
      name: 'count'
    },
    pageCount: {
      name: 'totalPages'
    },
    self: {
      active: false // Will not generate the self link
    },
    first: {
      active: false // Will not generate the first link
    },
    last: {
      active: false // Will not generate the last link
    }
  },
  routes: {
    include: ['/article'] // 須要開啓的路由
  }
}
module.exports = {
  plugin: hapiPagination,
  options
}
  1. 註冊插件
const pluginHapiPagination = require('./plugins/hapi-pagination');
await server.register([
  pluginHapiPagination,
])
  1. 加入參數校驗
const paginationDefine = {
  limit: Joi.number().integer().min(1).default(10)
    .description('每頁的條目數'),
  page: Joi.number().integer().min(1).default(1)
    .description('頁碼數'),
  pagination: Joi.boolean().description('是否開啓分頁,默認爲true')
}
// 某個接口
// joi校驗
...
validate: {
        query: {
          ...paginationDefine
        }
      }
...
  1. 數據庫查詢
const { rows: results, count: totalCount } = await models.xxxx.findAndCountAll({
      limit: request.query.limit,
      offset: (request.query.page - 1) * request.query.limit,
    });

3 最後

歡迎到線上地址體驗完整功能

1 踩坑總結:

  • 碰到接口500的狀況,能夠在model的操做後面捕獲錯誤,好比models.findAll().catch(e => console.log(e))
  • 注意版本兼容問題,插件和hapi或者nuxt版本的兼容
  • nuxt.config.ts的配置不生效能夠執行tsc nuxt.config.ts手動編譯
  • 在asyncData中請數據,不寫絕對地址,會默認請求80端口的

2 開發收穫

  • 熟悉了基本的後端開發流程
  • 插件不兼容或者有其餘需求的狀況下,必須本身看英文文檔,看到英文文檔能淡定了
  • 後端開發須要作的工做蠻多的,從接口到部署等,之後工做中要相互理解

3 參考

掘金小冊: 葉盛飛 《基於 hapi 的 Node.js 小程序後端開發實踐指南》

ps:歡迎點贊star ^_^
github: https://github.com/huoguozhang/my-blog

相關文章
相關標籤/搜索