Vue+Express+Mysql 全棧初體驗

麥樂丶發表於2019-05-25

前言

原文地址

曾幾何時,你有沒有想過一個前端工程師的未來是什麼樣的?這個時候你是不是會想到了一個詞”前端架構師“,那麼一個合格的前端架構只會前端OK嗎?那當然不行,你必須具備全棧的能力,這樣才能擴大個人的形象力,才能升職加薪,才能迎娶白富美,才能走向人生巔峰...

最近我在寫一些後端的專案,發現重複工作太多,尤其是框架部分,然後這就抽空整理了前後端的架子,主要是用的Vue,Express,資料儲存用的Mysql,當然如果有其他需要,也可以直接切換到sqlite、postgres或者mssql。

先獻上專案原始碼地址

專案

專案以todolist為?,簡單的實現了前後端的CURD。

後端技術棧

前端技術棧

專案結構

先看專案架構,client為前端結構,server為後端結構

|-- express-vue-web-slush
    |-- client
    |   |-- http.js   // axios 請求封裝
    |   |-- router.js  // vue-router
    |   |-- assets  // 靜態資源
    |   |-- components  // 公用元件
    |   |-- store  // store
    |   |-- styles // 樣式
    |   |-- views // 檢視
    |-- server
        |-- api    // controller api檔案
        |-- container  // ioc 容器
        |-- daos  // dao層
        |-- initialize  // 專案初始化檔案
        |-- middleware  // 中介軟體
        |-- models  // model層
        |-- services // service層
複製程式碼

程式碼介紹

前端程式碼就不多說,一眼就能看出是vue-cli生成的結構,不一樣的地方就是前端編寫的程式碼是以Vue Class的形式編寫的,具體細節請見從react轉職到vue開發的專案準備

然後這裡主要描述一下後端程式碼。

熱更新

開發環境必需品,我們使用的是nodemon,在專案根目錄新增nodemon.json

{
  "ignore": [
    ".git",
    "node_modules/**/node_modules",
    "src/client"
  ]
}
複製程式碼

ignore忽略 node_modules 和 前端程式碼資料夾src/client 的js檔案變更,ignore以外的js檔案變更nodemon.json會重啟node專案。

這裡為了方便,我寫了一個指令碼,同時啟動前後端專案,如下:

import * as childProcess from 'child_process';

function run() {
  const client = childProcess.spawn('vue-cli-service', ['serve']);
  client.stdout.on('data', x => process.stdout.write(x));
  client.stderr.on('data', x => process.stderr.write(x));

  const server = childProcess.spawn('nodemon', ['--exec', 'npm run babel-server'], {
    env: Object.assign({
      NODE_ENV: 'development'
    }, process.env),
    silent: false
  });
  server.stdout.on('data', x => process.stdout.write(x));
  server.stderr.on('data', x => process.stderr.write(x));

  process.on('exit', () => {
    server.kill('SIGTERM');
    client.kill('SIGTERM');
  });
}
run();
複製程式碼

前端用vue-cli的vue-cli-service命令啟動。

後端用nodemon執行babel-node命令啟動

然後這前後端專案由node子程式啟動,然後我們在package.json裡新增script。

{
    "scripts": {
        "dev-env": "cross-env NODE_ENV=development",
        "babel-server": "npm run dev-env && babel-node --config-file ./server.babel.config.js -- ./src/server/main.js",
        "dev": "babel-node --config-file ./server.babel.config.js -- ./src/dev.js",
    }
}
複製程式碼

server.babel.config.js為後端的bable編譯配置。

專案配置

所謂的專案配置呢,說的就是與業務沒有關係的系統配置,比如你的日誌監控配置、資料庫資訊配置等等

首先,在專案裡面新建配置檔案,config.properties,比如我這裡使用的是Mysql,內容如下:

[mysql]
host=127.0.0.1
port=3306
user=root
password=root
database=test
複製程式碼

在專案啟動之前,我們使用properties對其進行解析,在我們的server/initialize新建properties.js,對配置檔案進行解析:

import properties from 'properties';
import path from 'path';

const propertiesPath = path.resolve(process.cwd(), 'config.properties');

export default function load() {
  return new Promise((resolve, reject) => {
    properties.parse(propertiesPath, { path: true, sections: true }, (err, obj) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(obj);
    });
  }).catch(e => {
    console.error(e);
    return {};
  });
}
複製程式碼

然後在專案啟動之前,初始化mysql,在server/initialize資料夾新建檔案index.js

import loadProperties from './properties';
import { initSequelize } from './sequelize';
import container from '../container';
import * as awilix from 'awilix';
import { installModel } from '../models';

export default async function initialize() {
  const config = await loadProperties();
  const { mysql } = config;
  const sequelize = initSequelize(mysql);
  installModel(sequelize);
  container.register({
    globalConfig: awilix.asValue(config),
    sequelize: awilix.asValue(sequelize)
  });
}
複製程式碼

這裡我們資料持久化用的sequelize,依賴注入用的awilix,我們下文描述。

初始化所有配置後,我們在專案啟動之前執行initialize,如下:

import express from 'express';
import initialize from './initialize';
import fs from 'fs';

const app = express();

export default async function run() {
  await initialize(app);

  app.get('*', (req, res) => {
    const html = fs.readFileSync(path.resolve(__dirname, '../client', 'index.html'), 'utf-8');
    res.send(html);
  });

  app.listen(9001, err => {
    if (err) {
      console.error(err);
      return;
    }
    console.log('Listening at http://localhost:9001');
  });
}

run();
複製程式碼

資料持久化

作為前端,對資料持久化這個詞沒什麼概念,這裡簡單介紹一下,首先資料分為兩種狀態,一種是瞬時狀態,一種是持久狀態,而瞬時狀態的資料一般是存在記憶體中,還沒有永久儲存的資料,一旦我們伺服器掛了,那麼這些資料將會丟失,而持久狀態的資料呢,就是已經落到硬碟上面的資料,比如mysql、mongodb的資料,是儲存在硬碟裡的,就算伺服器掛了,我們重啟服務,還是可以獲取到資料的,所以資料持久化的作用就是將我們的記憶體中的資料,儲存在mysql或者其他資料庫中。

我們資料持久化是用的sequelize,它可以幫我們對接mysql,讓我們快速的對資料進行CURD。

下面我們在server/initialize資料夾新建sequelize.js,方便我們在專案初始化的時候連線:

import Sequelize from 'sequelize';

let sequelize;

const defaultPreset = {
  host: 'localhost',
  dialect: 'mysql',
  operatorsAliases: false,
  port: 3306,
  pool: {
    max: 10,
    min: 0,
    acquire: 30000,
    idle: 10000
  }
};

export function initSequelize(config) {
  const { host, database, password, port, user } = config;
  sequelize = new Sequelize(database, user, password, Object.assign({}, defaultPreset, {
    host,
    port
  }));
  return sequelize;
};

export default sequelize;
複製程式碼

initSequelize的入參config,來源於我們的config.properties,在專案啟動之前執行連線。

然後,我們需要對應資料庫的每個表建立我們的Model,以todolist為例,在service/models,新建檔案ItemModel.js

export default function(sequelize, DataTypes) {
    const Item = sequelize.define('Item', {
        recordId: {
            type: DataTypes.INTEGER,
            field: 'record_id',
            primaryKey: true
        },
        name: {
            type: DataTypes.STRING,
            field: 'name'
        },
        state: {
            type: DataTypes.INTEGER,
            field: 'state'
        }
    }, {
        tableName: 'item',
        timestamps: false
    });
    return Item;
}
複製程式碼

然後在service/models,新建index.js,用來匯入models資料夾下的所有model:

import fs from 'fs';
import path from 'path';
import Sequelize from 'sequelize';

const db = {};

export function installModel(sequelize) {
  fs.readdirSync(__dirname)
    .filter(file => (file.indexOf('.') !== 0 && file.slice(-3) === '.js' && file !== 'index.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;
}

export default db;
複製程式碼

這個installModel也是在我們專案初始化的時候執行的。

model初始化完了之後,我們就可以定義我們的Dao層,使用model了。

依賴注入

依賴注入(DI)是反轉控制(IOC)的最常用的方式。最早聽說這個概念的相信大多數都是來源於Spring,反轉控制最大的作用的幫我們建立我們所需要是例項,而不需要我們手動建立,而且例項的建立的依賴我們也不需要關心,全都由IOC幫我們管理,大大的降低了我們程式碼之間的耦合性。

這裡用的依賴注入是awilix,首先我們建立容器,在server/container,下新建index.js

import * as awilix from 'awilix';

const container = awilix.createContainer({
  injectionMode: awilix.InjectionMode.PROXY
});

export default container;
複製程式碼

然後在我們專案初始化的時候,用awilix-express初始化我們後端的router,如下:

import { loadControllers, scopePerRequest } from 'awilix-express';
import { Lifetime } from 'awilix';

const app = express();

app.use(scopePerRequest(container));

app.use('/api', loadControllers('api/*.js', {
  cwd: __dirname,
  lifetime: Lifetime.SINGLETON
}));
複製程式碼

然後,我們可以在server/api下新建我們的controller,這裡新建一個TodoApi.js

import { route, GET, POST } from 'awilix-express';

@route('/todo')
export default class TodoAPI {

  constructor({ todoService }) {
    this.todoService = todoService;
  }

  @route('/getTodolist')
  @GET()
  async getTodolist(req, res) {
    const [err, todolist] = await this.todoService.getList();
    if (err) {
      res.failPrint('服務端異常');
      return;
    }
    res.successPrint('查詢成功', todolist);
  }

  //  ...
}
複製程式碼

這裡可以看到建構函式的入參注入了Service層的todoService例項,然後可以直接使用。

然後,我們要搞定我們的Service層和Dao層,這也是在專案初始化的時候,告訴IOC我們所有Service和Dao檔案:

import container from './container';
import { asClass } from 'awilix';

// 依賴注入配置service層和dao層
container.loadModules(['services/*.js', 'daos/*.js'], {
  formatName: 'camelCase',
  register: asClass,
  cwd: path.resolve(__dirname)
});
複製程式碼

然後我們可以在services和daos資料夾下肆無忌憚的新建service檔案和dao檔案了,這裡我們新建一個TodoService.js


export default class TodoService {
  constructor({ itemDao }) {
    this.itemDao = itemDao;
  }

  async getList() {
    try {
      const list = await this.itemDao.getList();
      return [null, list];
    } catch (e) {
      console.error(e);
      return [new Error('服務端異常'), null];
    }
  }

  // ...
}
複製程式碼

然後,新建一個Dao,ItemDao.js,用來對接ItemModel,也就是mysql的Item表:

import BaseDao from './base';

export default class ItemDao extends BaseDao {
    
    modelName = 'Item';

    constructor(modules) {
      super(modules);
    }

    async getList() {
      return await this.findAll();
    }
}
複製程式碼

然後搞一個BaseDao,封裝一些資料庫的常用操作,程式碼太長,就不貼了,詳情見程式碼庫

關於事務

所謂事務呢,簡單的比較好理解,比如我們執行了兩條SQL,用來新增兩條資料,當第一條執行成功了,第二條沒執行成功,這個時候我們執行事務的回滾,那麼第一條成功的記錄也將會被取消。

然後呢,我們這裡為了也滿足事務,我們可以按需使用中介軟體,為請求注入事務,然後所以在這個請求下執行的增刪改的SQL,都使用這個事務,如下中介軟體:

import { asValue } from 'awilix';

export default function () {
  return function (req, res, next) {
    const sequelize = container.resolve('sequelize');
    sequelize.transaction({  // 開啟事務
      autocommit: false
    }).then(t => {
      req.container = req.container.createScope(); // 為當前請求新建一個IOC容器作用域
      req.transaction = t;
      req.container.register({  // 為IOC注入一個事務transaction
        transaction: asValue(t)
      });
      next();
    });
  }
}
複製程式碼

然後當我們需要提交事務的時候,我們可以使用IOC注入transaction,例如,我們在TodoService.js中使用事務


export default class TodoService {
  constructor({ itemDao, transaction }) {
    this.itemDao = itemDao;
    this.transaction = transaction;
  }

  async addItem(item) {
    // TODO: 新增item資料
    const success = await this.itemDao.addItem(item);
    if (success) {
      this.transaction.commit(); // 執行事務提交
    } else {
      this.transaction.rollback(); // 執行事務回滾
    }
  }

  // ...
}
複製程式碼

其他

當我們需要在Service層或者Dao層使用到當前的請求物件怎麼辦呢,這個時候我們需要在IOC中為每一條請求注入request和response,如下中介軟體:

import { asValue } from 'awilix';

export function baseMiddleware(app) {
  return (req, res, next) => {
    res.successPrint = (message, data) => res.json({ success: true, message, data });

    res.failPrint = (message, data) => res.json({ success: false, message, data });
    req.app = app;

    // 注入request、response
    req.container = req.container.createScope();
    req.container.register({
      request: asValue(req),
      response: asValue(res)
    });
    next();
  }
}
複製程式碼

然後在專案初始化的時候,使用該中介軟體:

import express from 'express';

const app = express();
app.use(baseMiddleware(app));
複製程式碼

關於部署

使用pm2,簡單實現部署,在專案根目錄新建pm2.json

{
  "apps": [
    {
      "name": "vue-express",  // 例項名
      "script": "./dist/server/main.js",  // 啟動檔案
      "log_date_format": "YYYY-MM-DD HH:mm Z",  // 日誌日期資料夾格式
      "output": "./log/out.log",  // 其他日誌
      "error": "./log/error.log", // error日誌
      "instances": "max",  // 啟動Node例項數
      "watch": false, // 關閉檔案監聽重啟
      "merge_logs": true,
      "env": {
        "NODE_ENV": "production"
      }
    }
  ]
}
複製程式碼

這個時候,我們需要把客戶端和服務端編譯到dist目錄,然後將服務端的靜態資源目錄指向客戶端目錄,如下:

app.use(express.static(path.resolve(__dirname, '../client')));
複製程式碼

新增vue-cli的配置檔案vue.config.js:

const path = require('path');
const clientPath = path.resolve(process.cwd(), './src/client');
module.exports = {
  configureWebpack: {
    entry: [
      path.resolve(clientPath, 'main.js')
    ],
    resolve: {
      alias: {
        '@': clientPath
      }
    },
    devServer: {
      proxy: {
        '/api': { // 開發環境將API字首配置到後端埠
          target: 'http://localhost:9001'
        }
      }
    }
  },
  outputDir: './dist/client/'
};
複製程式碼

在package.json中新增如下script:

{
  "script": {
    "clean": "rimraf dist",
    "pro-env": "cross-env NODE_ENV=production",
    "build:client": "vue-cli-service build",
    "build:server": "babel --config-file ./server.babel.config.js src/server --out-dir dist/server/",
    "build": "npm run clean && npm run build:client && npm run build:server",
    "start": "pm2 start pm2.json",
    "stop": "pm2 delete pm2.json"
  }
}
複製程式碼

執行build命令,清理dist目錄,同時編譯前後端程式碼到dist目錄下,然後npm run start,pm2啟動dist/server/main.js;

到此為止,部署完成。

結束

發現自己掛羊頭賣狗肉,竟然全在寫後端。。。好吧,我承認我本來就是想寫後端的,但是我還是覺得作為一個前端工程師,Nodejs應該是在這條路上走下去的必備技能,加油~。

專案原始碼地址