Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue+Express+Mysql 全栈初体验 #24

Open
yacan8 opened this issue May 13, 2020 · 0 comments
Open

Vue+Express+Mysql 全栈初体验 #24

yacan8 opened this issue May 13, 2020 · 0 comments

Comments

@yacan8
Copy link
Owner

yacan8 commented May 13, 2020

前言

曾几何时,你有没有想过一个前端工程师的未来是什么样的?这个时候你是不是会想到了一个词”前端架构师“,那么一个合格的前端架构只会前端OK吗?那当然不行,你必须具备全栈的能力,这样才能扩大个人的形象力,才能升职加薪,才能迎娶白富美,才能走向人生巅峰...

最近我在写一些后端的项目,发现重复工作太多,尤其是框架部分,然后这就抽空整理了前后端的架子,主要是用的Vue,Express,数据存储用的Mysql,当然如果有其他需要,也可以直接切换到sqlite、postgres或者mssql。

先献上项目github

项目

项目以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 https://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: 'https://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应该是在这条路上走下去的必备技能,加油~。

项目github

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant