一個前端渣渣的node開發體驗

前言

​ 由於最近打算本身搭建一個本身的博客系統,用來記錄平常的學習和提高一下寫做水平,因此能就打算本身搭建一下先後端項目。在網上找了下,也沒有找到合適(現成)的項目,因此就打算本身動手來搭建一下。這篇文章主要描述如何搭建一個node的API接口服務。html

技術棧簡述

網上的node框架也挺多的,用的較多的有egg,express,koa等框架,框架間各有利弊,最後均衡下來,仍是決定使用可拓展性比較強的koa2來搭建項目,加上最近在學習typescript,最後決定使用的技術棧就是 koa+typescript+mysql+mongodb來搭建項目。前端

爲何要用node

最主要的一點是其餘語言咱也不會啊。。。node

無奈表情包

言歸正傳,Node.js是一個運行在服務端的框架,它底層使用的是V8引擎,它的速度很是快,而且做爲一個前端的後端服務語言,還有其餘吸引人的地方:mysql

  1. 異步I/Ogit

    由於node都是I/O都是異步的,因此能很好的處理高併發場景es6

  2. 事件驅動web

  3. 單線程sql

  4. 跨平臺mongodb

並且,最最最最重要的一點就是,node是由JavaScript開發的,極大的下降了前端同窗的學習成本。typescript

Koa

​ koa是Express的原班人馬打造的一個新的框架。相對於express來講koa更小,更有表現力更加健壯。固然,前面說的都是虛的,其實真正吸引個人是koa經過es6的寫法,利用async函數,解決了express.js中地獄回調的問題,而且koa不像express同樣自帶那麼多中間件,對於一個私有項目來講,無疑是極好的,還有一個特色就是koa獨特的中間件流程控制,也就是你們津津樂道的koa洋蔥模型。

關於洋蔥模型,大概概括起來就是兩點

  1. context的保存和傳遞
  2. 中間件的管理和next的實現

clipboard.png

​ (圖片來源於網絡)

img

上面兩張圖很清晰的展現了洋蔥模型的工做流程,固然,具體的原理實現的話與本篇無關,就不在深刻描述了,有興趣的同窗能夠本身到網上搜一下哈。

Typescript

網上特別多關於「爲何要用Typescript開發」,「Typescript開發的好處和壞處」,「爲何不用Typescript開發」等等的爭論和文章,有興趣的同窗也能夠去說道說道哈。

本次項目用ts主要是出於如下幾點考慮:

  • 本人在持續的學習ts中,「紙上得來終覺淺,絕知此事要躬行」,須要更多的ts實戰才能加深對ts的瞭解
  • 本身的項目,想用什麼就用什麼
  • 寫起來逼格會相對高一些
  • Ts有諸多js中沒有的東西,譬如泛型接口抽象等等
  • 良好的模塊管理
  • 強類型語音,我的感受比js開發服務端項目更合適
  • 有良好的錯誤提示機制,能夠避免不少開發階段的低級錯誤
  • 約束開發習慣,使得代碼更優雅規範

最後記住一點,適合本身的纔是最好的

Mysql

MySQL 是最流行的關係型數據庫管理系統,在 WEB 應用方面 MySQL 是最好的 RDBMS(Relational Database Management System:關係數據庫管理系統)應用軟件之一

Mongodb

爲何用了mysql還要用mongodb呢?其實主要是由於使用的是jwt來作一個身份認證,因爲用到中間件沒有提供刷新過時時間的API,而又想要實現一個自動續命的功能,因此使用mongodb來輔助完成自動續命的功能。而且,一些用戶身份信息或埋點信息能夠存在mongo中

PM2

PM2是node進程管理工具,能夠利用它來簡化不少node應用管理的繁瑣任務,如性能監控、自動重啓、負載均衡等,並且使用很是簡單

項目搭建

我主要把項目分爲:框架,日誌,配置,路由,請求邏輯處理,數據模型化這幾個模塊

如下是一個項目的目錄結構:

├── app                         編譯後項目文件
  ├── node_modules                依賴包
  ├── static                      靜態資源文件
  ├── logs                      	服務日誌
  ├── src                         源碼
  │   ├── abstract                    抽象類
  │   ├── config                      配置項
  │   ├── controller                  控制器
  │   ├── database                    數據庫模塊
  │   ├── middleware                  中間件模塊
  │   ├── models                  		數據庫表模型
  │   ├── router                      路由模塊 - 接口
  │   ├── utils                       工具
  │   ├── app.ts                  koa2入口
  ├── .eslintrc.js                eslint 配置
  ├── .gitignore                  忽略提交到git目錄文件
  ├── .prettierrc                 代碼美化
  ├── ecosystem.config.js         pm2 配置
  ├── nodemon.json                nodemon 配置
  ├── package.json                依賴包及配置信息文件
  ├── tsconfig.json               typescript 配置
  ├── README.md                   描述文件
複製代碼

話很少說,接下來跟着代碼來看項目

建立一個koa應用

俗話說的好:人無頭不走。項目中也會有個牽着項目走的頭,這就是入口app.ts,接下來咱就結合代碼看看它是怎麼作這個頭的

import Koa, { ParameterizedContext } from 'koa'
import logger from 'koa-logger'
// 實例化koa
const app = new Koa()
app.use(logger())
// 答應一下響應信息
app.use(async (ctx, next) => {
  const start = (new Date()).getDate();
  let timer: number
  try {
    await next()
    timer = (new Date()).getDate()
    const ms = timer - start
    console.log(`method: ${ctx.method}, url:${ctx.url} - ${ms}ms`)
  } catch (e) {
    timer = (new Date()).getDate()
    const ms = timer - start
    console.log(`method: ${ctx.method}, url:${ctx.url} - ${ms}ms`)
  }
})
// 監聽端口並啓動
app.listen(config.PORT, () => {
  console.log(`Server running on http://localhost:${config.PORT || 3000}`)
})
app.on('error', (error: Error, ctx: ParameterizedContext) => {
  // 項目啓動錯誤
  ctx.body = error;
})
export default app
複製代碼

到了這一步,咱們就已經能夠啓動一個簡單的項目了

  1. npm run tsc 編譯ts文件
  2. node app.js 啓動項目

接下來在瀏覽器輸入http://localhost:3000就能在控制檯看到訪問日誌了。固然,作到這一步仍是不夠的,由於咱們開發過程當中老是伴隨着調試,因此須要更方便的開發環境。

本地開發環境

本地開發使用nodemon來實現自動重啓,由於node不能直接識別ts,因此須要用ts-node來運行ts文件。

// nodemon.json
{
  "ext": "ts",
  "watch": [ // 須要監聽變化的文件
    "src/**/*.ts",
    "config/**/*.ts",
    "router/**/*.ts",
    "public/**/*",
    "view/**/*"
  ],
  "exec": "ts-node --project tsconfig.json src/app.ts" // 使用ts-node來執行ts文件
}
// package.json
"scripts": {
  "start": "cross-env NODE_ENV=development nodemon -x"
}
複製代碼

本地調試

由於有的時候須要看到請求的信息,那咱們又不能在代碼中添加console.log(日誌)這樣效率低又不方便,因此咱們須要藉助編輯器來幫咱們實現debug的功能。這邊簡單描述一下怎麼用vscode來實現debug的。

  • tsconfig.json中開啓sourceMap
  • 爲ts-node註冊一個vsc的debug任務,修改項目的launch.json文件,添加一個新的啓動方式
  • launch.json
{
  "name": "Current TS File",
  "type": "node",
  "request": "launch",
  "args": [
    "${workspaceRoot}/src/app.ts" // 入口文件
  ],
  "runtimeArgs": [
    "--nolazy",
    "-r",
    "ts-node/register"
  ],
  "sourceMaps": true,
  "cwd": "${workspaceRoot}",
  "protocol": "inspector",
  "console": "integratedTerminal",
  "internalConsoleOptions": "neverOpen"
}
複製代碼
  • F9 代碼中斷點
  • F5 開始調試代碼

引入接口路由

上面咱們已經建立了一個koa應用了,接下來就使用須要引入路由了:

// app.ts
import router from './router'
import requestMiddleware from './middleware/request'
app
  .use(requestMiddleware) // 使用路由中間件處理路由,一些處理接口的公用方法
  .use(router.routes())
  .use(router.allowedMethods())

// router/index.ts
import { ParameterizedContext } from 'koa'
import Router from 'koa-router'
const router = new Router()
// 接口文檔 - 這邊使用分模塊實現路由的方式
router.use(路由模塊.routes())
...
router.use(路由模塊.routes())
// 測試路由鏈接
router.get('/test-connect', async (ctx: ParameterizedContext) => {
  await ctx.body = 'Hello Frivolous'
})
// 匹配其餘未定義路由
router.get('*', async (ctx: ParameterizedContext) => {
  await ctx.render('error')
})
export default router
複製代碼

定義數據庫模型

  1. 使用sequlize做爲mysql的中間件
// 實例化sequelize
import { Sequelize } from 'sequelize'
const sequelizeManager = new Sequelize(db, user, pwd, Utils.mergeDefaults({
    dialect: 'mysql',
    host: host,
    port: port,
    define: {
      underscored: true,
      charset: 'utf8',
      collate: 'utf8_general_ci',
      freezeTableName: true,
      timestamps: true,
    },
    logging: false,
  }, options));
}
// 定義表結構
import { Model, ModelAttributes, DataTypes } from 'sequelize'
// 定義用戶表模型中的字段屬性
const UserModel: ModelAttributes = {
  id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    primaryKey: true,
    unique: true,
    autoIncrement: true,
    comment: 'id'
  },
  avatar: {
    type: DataTypes.INTEGER,
    allowNull: true
  },
  nick_name: {
    type: DataTypes.STRING(50),
    allowNull: true
  },
  email: {
    type: DataTypes.STRING(50),
    allowNull: true
  },
  mobile: {
    type: DataTypes.INTEGER,
    allowNull: false
  },
  gender: {
    type: DataTypes.STRING(35),
    allowNull: true
  },
  age: {
    type: DataTypes.INTEGER,
    allowNull: true
  },
  password: {
    type: DataTypes.STRING(255),
    allowNull: false
  }
}
// 定義表模型
sequelizeManager.define(modelName, UserModel, {
  freezeTableName: true, // model對應的表名將與model名相同
  tableName: modelName,
  timestamps: true,
  underscored: true,
  paranoid: true,
  charset: "utf8",
  collate: "utf8_general_ci",
})
複製代碼

根據上面的代碼,咱們就已經定義好一個user表了,其餘表也能夠按照這個來定義。不過這個項目除了使用mysql,也還有用到mongo,接下來看看mongodb怎麼用

  1. 使用mongoose做爲mongodb的中間件
// mongoose入口
import mongoose from 'mongoose'
const uri = `mongodb://${DB.host}:${DB.port}`
mongoose.connect('mongodb://' + DB_STR)
mongoose.connection.on('connected', () => {
  log('Mongoose connection success')
})
mongoose.connection.on('error', (err: Error) => {
  log('Mongoose connection error: ' + err.message)
})
mongoose.connection.on('disconnected', () => {
  log('Mongoose connection disconnected')
})
export default mongoose

// 定義表模型
import mongoose from '../database/mongoose'
const { Schema } = mongoose
const AccSchema = new Schema({}, {
  strict: false, // 容許傳入未定義字段
  timestamps: true, // 默認會帶上createTime/updateTime
  versionKey: false // 默認不帶版本號
})
export default AccSchema

// 定義模型
mongoose.model('AccLog', AccSchema)
複製代碼

實現接口

好了,上面咱們已經定義好表模型了,接下來就是激動人心的接口實現了。咱們經過一個簡單的埋點接口來實現一下,首先須要分析埋點工具實現的邏輯:

  1. 由於埋點信息都是非關係型的,因此使用mongodb來存儲埋點信息
  2. 由於這個就是一個單純的記錄接口,因此須要設計的比較通用 - 即除了關鍵幾個字段,調用方傳什麼就保存什麼
  3. 埋點行爲對用戶來講是無感知的,因此不設計反饋信息,若是埋點出錯也是由內部處理

好了,瞭解這個埋點的功能以後,就開始來實現這個簡單的接口了:

// route.ts 定義一個addAccLog的接口
router.post('/addAccLog', AccLogController.addAccLog)
// AccLogController.ts 實現addAccLog接口
class AccLogRoute extends RequestControllerAbstract {
  constructor() {
    super()
  }
  // AccLogController.ts
  public async addAccLog(ctx: ParameterizedContext): Promise<void> {
    try {
      const data = ctx.request.body
      const store = Mongoose.model(tableName, AccSchema, tableName)
      // disposeAccInsertData 方法用來處理日誌信息,有些字段嵌套太要扁平化深或者去除空值冗餘字段
      const info = super.disposeAccInsertData(data.logInfo)
      // 添加日誌
      const res = await store.create(info)
      // 不須要反饋
      // super.handleResponse('success', ctx, res)
    } catch (e) {
      // 錯誤處理 - 好比說打個點,記錄埋點出錯的信息,看看是什麼緣由致使出錯的(根據實際的需求來作)
      // ...
    }
  }
  // ...
}
export default new AccLogRoute()
複製代碼

說到這邊,不得不提一句哈,就是路由能夠引入裝飾器寫法,這樣能減小重複工做和提升效率,有興趣的同窗能夠看我上一篇博客哈。這邊貼一下裝飾器寫法的代碼:

@Controller('/AccLogController')
class AccLogRoute {
  @post('/addAccLog')
  @RequestBody({}) 
  async addAccLog(ctx: ParameterizedContext, next: Function) {
    const res = await store.create(info)
    handleResponse('success', ctx, res)
  }
}
複製代碼

這一對比,是否是看出裝飾器的好處了呢。

jwt身份驗證

這邊使用jsonwebtoken來作jwt校驗

import { sign, decode, verify } from 'jsonwebtoken'
import { ParameterizedContext } from 'koa'
import { sign, decode, verify } from 'jsonwebtoken'
import uuid from 'node-uuid'

import IController from '../interface/controller'
import config from '../config'
import rsaUtil from '../util/rsaUtil'
import cacheUtil from '../util/cacheUtil'

interface ICode {
  success: string,
  unknown: string,
  error: string,
  authorization: string,
}

interface IPayload {
  iss: number | string; // 用戶id
  login_id: number | string; // 登陸日誌id
  sub?: string;
  aud?: string;
  nbf?: string;
  jti?: string;
  [key: string]: any;
}

abstract class AController implements IController {
  // 服務器響應狀態
  // code 狀態碼參考 https://www.cnblogs.com/zqsb/p/11212362.html
  static STATE = {
    success: { code: 200, message: '操做成功!' },
    unknown: { code: -100, message: '未知錯誤!' },
    error: { code: 400, message: '操做失敗!' },
    authorization: { code: 401, message: '身份認證失敗!' },
  }

  /** * @description 響應事件 * @param {keyof ICode} type * @param {ParameterizedContext} [ctx] * @param {*} [data] * @param {string} [message] * @returns {object} */
  public handleResponse(
    type: keyof ICode,
    ctx?: ParameterizedContext,
    data?: any,
    message?: string
  ): object {
    const res = AController.STATE[type]
    const result = {
      message: message || res.message,
      code: res.code,
      data: data || null,
    }
    if (ctx) ctx.body = result
    return result
  }
  /** * @description 註冊token * @param {IPayload} payload * @returns {string} */
  public jwtSign(payload: IPayload): string {
    const { TOKENEXPIRESTIME, JWTSECRET, RSA_PUBLIC_KEY } = config.JWT_CONFIG
    const noncestr = uuid.v1()
    const iss = payload.iss
    // jwt建立Token
    const token = sign({
      ...payload,
      noncestr
    }, JWTSECRET, { expiresIn: TOKENEXPIRESTIME, algorithm: "HS256" })
    // 加密Token
    const result = rsaUtil.pubEncrypt(RSA_PUBLIC_KEY, token)
    const isSave = cacheUtil.set(`${iss}`, noncestr, TOKENEXPIRESTIME * 1000)
    if (!isSave) {
      throw new Error('Save authorization noncestr error')
    }
    return `Bearer ${result}`
  }
  /** * @description 驗證Token有效性,中間件 * */
  public async verifyAuthMiddleware(ctx: ParameterizedContext, next: Function): Promise<any> {
    // 校驗token
    const { JWTSECRET, RSA_PRIVATE_KEY, IS_AUTH, IS_NONCESTR } = config.JWT_CONFIG
    if (!IS_AUTH && process.env.NODE_ENV === 'development') {
      await next()
    } else {
      // 若是header中沒有身份認證字段,則認爲校驗失敗
      if (!ctx.header || !ctx.header.authorization) {
        ctx.response.status = 401
        return
      }
      // 獲取token而且解析,判斷token是否一致
      const authorization: string = ctx.header.authorization;
      const scheme = authorization.substr(0, 6)
      const credentials = authorization.substring(7)
      if (scheme !== 'Bearer') {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'Wrong authorization prefix')
        return;
      }
      if (!credentials) {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'Request header authorization cannot be empty')
        return;
      }

      const token = rsaUtil.priDecrypt(RSA_PRIVATE_KEY, credentials)
      if (typeof token === 'object') {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'authorization is not an object')
        return;
      }
      const isAuth = verify(token, JWTSECRET)
      if (!isAuth) {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'authorization token expired')
        return;
      }
      const decoded: string | { [key: string]: any } | null = decode(token)
      if (typeof decoded !== 'object' || !decoded) {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'authorization parsing failed')
        return;
      }
      const noncestr = decoded.noncestr
      const exp = decoded.exp
      const iss = decoded.iss
      const cacheNoncestr = cacheUtil.get(`${iss}`)
      if (IS_NONCESTR && noncestr !== cacheNoncestr) {
        ctx.response.status = 401;
        this.handleResponse('authorization', ctx, null, 'authorization signature "noncestr" error')
        return;
      }
      if (Date.now() / 1000 - exp < 60) {
        const options = { ...decoded };
        Reflect.deleteProperty(options, 'exp')
        Reflect.deleteProperty(options, 'iat')
        Reflect.deleteProperty(options, 'nbf')
        const newToken = AController.prototype.jwtSign(options as IPayload)
        ctx.append('token', newToken)
      }
      ctx.jwtData = decoded
      await next()
    }
  }
}
export default AController
// 受權裝飾器代碼
public auth() {
  return (target: any, name?: string, descriptor?: IDescriptor) => {
    if (typeof target === 'function' && name === undefined && descriptor === undefined) {
      target.prototype.baseAuthMidws = super.verifyAuthMiddleware;
    } else if (typeof target === 'object' && name && descriptor) {
      descriptor.value.prototype.baseAuthMidws = super.verifyAuthMiddleware;
    }
  }
}
複製代碼

這樣,咱們就完成了一個jwt受權的模塊了,咱們用也很簡單,以addAccLog接口爲例

class AccLogRoute {
  @auth() // 只要➕這一行代碼就能夠
  @post('/addAccLog')
 	...
}
複製代碼

接口文檔

既然咱們已經寫好接口了,那總要有一份可參閱的文檔輸出,這時候就想到了swagger,接下來我們就把swagger引入到咱們的項目中吧。

  • 入口
// swagger入口
import swaggerJSDoc from 'swagger-jsdoc'
import config from '../config'
const { OPEN_API_DOC } = config
// swagger definition
const swaggerDefinition = {
  // ...
}
const createDOC = (): object => {
  const options = {
    swaggerDefinition: swaggerDefinition,
    apis: ['./src/controller/*.ts']
  }
  return OPEN_API_DOC ? swaggerJSDoc(options) : null
}
export default createDOC
// 怎麼
複製代碼
  • 配置示例 - 這邊必定要注意格式
@swagger Tips: 必需要聲明,否則代碼不會把此處生成爲文檔
 definitions:
   Login: // 接口名
     required: // 必填參數
       - username
       - password
     properties: // 可選參數
       username:
         type: string
       password:
         type: string
       path:
         type: string

複製代碼

Mock數據

使用mock來生成測試數據

日誌

日誌模塊原本打算是用log4.js來作的,後來感受作的日誌模塊還沒達到預期,因此就決定先暫時用pm2的日誌系統來代替log4。這邊就先不貼log4相關的代碼了

部署

使用pm2來部署項目,這邊展現一下配置文件

Tips

  • error_file 錯誤日誌輸出
  • out_file 正常日誌輸出
  • script 入口文件 - 以打包事後的js文件做爲入口
// pm2.json
{
  "apps": {
    "name": "xxx",
    "script": "./app/server.js",
    "cwd": "./",
    "args": "",
    "interpreter_args": "",
    "watch": true,
    "ignore_watch": [
      "node_modules",
      "logs",
      "app/lib"
    ],
    "exec_mode": "fork_mode",
    "instances": 1,
    "max_memory_restart": 8,
    "error_file": "./logs/pm2-err.log",
    "out_file": "./logs/pm2-out.log",
    "merge_logs": true,
    "log_date_format": "YYYY-MM-DD HH:mm:ss",
    "max_restarts": 30,
    "autorestart": true,
    "cron_restart": "",
    "restart_delay": 60,
    "env": {
      "NODE_ENV": "production"
    }
  }
}

// package.json
"scripts": {
  // 生產環
  "prod": "pm2 start pm2.json"
}
複製代碼

配置好pm2以後,咱們只要在package.json中配置pm2 start pm2.json就能夠實現啓動pm2進程了

結束語

雖然是一個簡單的接口服務器,可是須要考慮的東西也是不少,並且由於不少插件都是第一次接觸,因此整個項目實現的過程仍是蠻坎坷的,基本上是那種摸石頭過河。不過痛並快樂着吧,雖然困難不少,可是過程當中也學到了很多新的知識點,大概瞭解了一個簡單的後端服務項目所承載的重量,明白了簡單項目中後端的運行過程。最後以一句哀嚎結尾吧:一入前端深似海,新出框架學不動。 大佬們別學了,更不上了...

關於項目開源的事情,由於整個項目還在開發的過程當中,還有不少不完善的地方,因此就但願最後完成整個系統以後會第一時間把一系列的前端+後端+其餘代碼一併開源出去。

相關文章
相關標籤/搜索