前言
原文地址
曾几何时,你有没有想过一个前端工程师的未来是什么样的?这个时候你是不是会想到了一个词”前端架构师“,那么一个合格的前端架构只会前端 OK 吗?那当然不行,你必须具备全栈的能力,这样才能扩大个人的形象力,才能升职加薪,才能迎娶白富美,才能走向人生巅峰 …
最近我在写一些后端的项目,发现重复工作太多,尤其是框架部分,然后这就抽空整理了前后端的架子,主要是用的 Vue,Express,数据存储用的 Mysql,当然如果有其他需要,也可以直接切换到 sqlite、postgres 或者 mssql。
先献上项目源码地址
项目
项目以 todolist 为????,简单的实现了前后端的 CURD。
后端技术栈
- 框架 Express
- 热更新 nodemon
- 依赖注入 awilix
- 数据持久化 sequelize
- 部署 pm2
前端技术栈
- vue-router
- vuex
- axios
- vue-class-component
- vue-property-decorator
- vuex-class
项目结构
先看项目架构,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 应该是在这条路上走下去的必备技能,加油~。
项目源码地址