最近接手了一个Angular项目,因为原有项目缺乏Mock机制,因此重新搭建了一套,记录一下。
项目现状:
该项目是使用angular框架进行开发的MIS后台管理系统。接手后,我首先对项目进行了架构上的调整,主要参考了angular项目的最佳实践,参考文章如下:
https://medium.com/@motcowley/angular-folder-structure-d1809be95542
https://itnext.io/planning-the-architecture-of-your-angular-app-a4840bfec13b
https://itnext.io/choosing-a-highly-scalable-folder-structure-in-angular-d987de65ec7
https://medium.com/dev-jam/5-tips-best-practices-to-organize-your-angular-project-e900db08702e
具体的不详述,最终优化后的项目结构如下:
├── e2e
├── src
│ ├── app
│ │ ├── core
│ │ │ ├── models
│ │ │ └── services
│ │ ├── shared
│ │ │ ├── components # container of all general components
│ │ │ └── utils # container of all general functions
│ │ ├── views # container of all business pages
│ │ │ ├── alarm
│ │ │ └── ......
│ │ ├── app-routing.module.ts
│ │ ├── app.component.css
│ │ ├── app.component.less
│ │ ├── app.component.html
│ │ ├──app.component.ts
│ ├── assets
│ │ ├── json # container of mock data assets
│ │ ├── i18n # container of internationalization assets
│ │ └── images
│ ├── environments
│ ├── favicon.ico
│ ├── index.html
│ ├── style.css
│ ├── style.less
│ ├── my-theme.css
│ ├── my-theme.less
│ ├── main.ts
│ ├── polyfill.ts
│ ├── test.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.spec.json
│ ├── typing.d.ts
├── .angular-cli.json
├── CHANGELOG.md # recorder of all the important changes
├── karma.conf.js
├── localproxy.conf.json # config for mock server proxy
├── proxy.conf.json # config for server proxy
├── tsconfig.json
├── package.json
└── README.md
其中,最重要的部分是app文件夹,承载了项目中最重要的业务代码。除此之外,值得注意的是,原有项目将mock数据资源放在 /assets/json 路径下进行统一管理,每次调用文件时,都需要从这个路径下读取数据。
优化原因:
如上所述,目前的项目将mock数据放在assets中进行管理,通过改变各个模块的service中的数据请求路径来实现mock功能。因此,每次需要mock数据时,都需要修改每个service中的路径,引入本地的json文件。例如:
//home.service.ts -------------local代码
…………
baseUrl = "./assets/json/"
url = {
home_serviceData: this.baseUrl + "/home_serviceData.json",
home_alarmData: this.baseUrl + "/home_alarmData.json",
home_alarmChartData: this.baseUrl + "/home_alarmChartData.json",
home_servicebarData:this.baseUrl + "/home_servicebar.json",
sourceNames: this.baseUrl + "/SourceName.json",
listSortMasters:this.baseUrl+"/listSortMsters.json",
}
//home.service.ts -------------线上代码
…………
url = {
home_serviceData: this.baseUrl + "/uui-lcm/serviceNumByCustomer",
home_alarmData: this.baseUrl + "/alarm/statusCount",
home_alarmChartData: this.baseUrl + "/alarm/diagram",
home_servicebarnsData: this.baseUrl + "/uui-lcm/ns-packages",
sourceNames: this.baseUrl + "/alarm/getSourceNames",
listSortMasters: this.baseUrl + "/listSortMasters",
}
如上所示,如果需要实现home模块的mock功能,需要编写线上和本地两套代码,在本地开发时将线上代码删除,而线上联调时,又需要将local地址删除。
这样的开发方式使mock数据十分麻烦。为了减轻负担,原有的开发方式是将项目拆为线上和线下两套代码,这样省去了反复注释和删除代码的麻烦。但是这种做法同样存在很大弊端:1. 在两套代码中来回粘贴代码使得开发效率降低,且很容易因为误粘贴或是漏粘贴出现功能错误;2. 每次mock均需要大量修改service代码,service模块是angular项目的核心模块,一旦误删会导致严重的问题;3. 这种做法使无关代码参与到mock数据中来,不够优雅;
因此,需要建立一套mock数据机制,改变现有的两套代码的开发方式。
优化目标:
为解决现有mock方式存在的弊端,同时满足项目开发的新需求,新的mock方式需要达成以下目标:
- 建立一键启动mock服务机制,使一套代码可以同时兼容线上和线下两种开发环境,废弃之前两套代码间相互粘贴的开发模式;
- 隔离业务代码和mock数据功能,使mock数据部分的代码成为一个独立的模块,从而避免对业务代码的干扰;
- 通过mock数据实现真正的前后端分离,使项目既能兼容原有的mock数据,又能满足新功能快速开发的需求;
技术选型:
json-server+faker.js
这两个都是好东西,具体不详述,可以看官网文档和参考文章:
https://github.com/typicode/json-server
https://github.com/marak/Faker.js/
https://www.npmjs.com/package/faker
https://segmentfault.com/a/1190000008574028
https://juejin.im/post/5b06e6426fb9a07aa34aaa8d
项目优化难点:
虽然json-server
可以解决大部分的请求数据返回,但是本项目还是有些特殊的情况,这给mock数据造成了困难:
- 项目的请求路径中存在变量,因此无法通过本地json文件直接模拟
- 项目原有接口遵循RESTful规范,即:相同api,不同请求方式,则返回的数据不同,实现的功能也不同
解决方法:
采用如下方法可以解决项目优化中出现的难点:
rewrite,routes,json-server接口拦截机制(post to get)
项目优化思路:
以下是使用json-server
优化项目的思路:
解释一下思路。为了达到我们的优化目标,解决项目中的难点,采用了如图所示的五个步骤进行。
- 配置
package.json
文件,安装json-server
,设置启动服务的命令,同时配置代理,将本地服务代理至mock服务器上; - 创建
server.js
文件,用来丰富mock服务的配置内容; - 引入
faker.js
,用来支持新老功能同时开发; - 配置
routes.js
,用来支持多字段接口转发和可变请求路径; - 拦截接口,对非GET类的请求实现路径重拼和接口转发;
ok,可以开搞了…...
具体做法:
具体做法分为基础配置和自定义配置两部分。其中,自定义配置是针对我们项目的特有情况对json-server
进行的配置。
基础操作
- package.json文件配置
安装json-server:
npm install -g json-server
在package.json文件中使用json-server,添加serve的运行命令,指定mock服务接口并启动本地代理服务:
"mockproxy": "ng serve --proxy-config localproxy.conf.json”, //代理本地请求至mock服务器
"mockconfig": "node ./src/app/mock/server.js --port 3002”, //启动本地mock服务器
"mock": "npm run mockconfig | npm run mockproxy",
其中,npm run mock命令是启动mock服务和代理服务的集合。
以上,实现了通过npm命令一键启动mock服务的需求。接下来,需要对server.js文件进行配置。
- server配置
在app目录下,创建mock文件夹,用来管理mock的配置文件。这样,实现了项目mock数据与业务代码的隔离。
在mock文件夹中,创建server.js文件,用作对json-server服务做自定义配置,这也是整个mock系统中最终要的一个文件。
server.js文件的基础编写思路是:导入原有mock数据文件,之后将请求路径通过json-server的use方法进行使用,同时对接口进行转发。具体的编写步骤,不详述,上代码:
const jsonServer = require('json-server');
const server = jsonServer.create();
const middlewares = jsonServer.defaults();
const customersRouters = require('./routes');
const baseUrl = "/usecaseui-server/v1”;
// Set default middlewares
server.use(middlewares);
// Get mock data
const fs = require('fs');
const path = require('path');
let localJsonDb = {}; //import mock datas
const mockFolder = './src/app/mock/json'; //mock json path folder
const filePath = path.resolve(mockFolder);
fileDisplay(filePath);
function fileDisplay(filePath) {
let fileList = [];
// Return filelist on based of filePath
const files = fs.readdirSync(filePath);
files.forEach((filename) => {
// Get filename's absolute path
let filedir = path.join(filePath, filename);
// Get the file information according to the file path and return an fs.Stats object
fs.stat(filedir, (err, stats) => {
if (err) {
console.warn('Get files failed......');
} else {
let isFile = stats.isFile(); // files
let isDir = stats.isDirectory(); //files folder
if (isFile) {
fileList.push(path.basename(filedir, '.json'));
fileList.forEach(item => {
localJsonDb[item] = getjsonContent(item);
})
}
if (isDir) {
fileDisplay(filedir);
}
Object.keys(fakeoriginalData).map(item => {
localJsonDb[item] = fakeoriginalData[item];
})
}
})
})
setTimeout(() => {
runServer(localJsonDb);
}, 100)
}
function getjsonContent(path) {
let newpath = `./src/app/mock/json/${path}.json`;
let result = JSON.parse(fs.readFileSync(newpath));
return result;
}
function serverRewrite() {
server.use(jsonServer.rewriter(customersRouters))
}
function runServer(db) {
server.use(jsonServer.router(db));
}
server.listen(3002, () => {
console.log('Mock Server is successfully running on port 3002 😁')
});
有几点需要说明:
第一,为了尽量减小修改成本,本项目直接读取了mock文件夹中的数据,同时,将这些文件的文件名直接引用为接口的转发路径:
………
let fileList = [];
// Return filelist on based of filePath
const files = fs.readdirSync(filePath);
files.forEach((filename) => {
// Get filename's absolute path
let filedir = path.join(filePath, filename);
// Get the file information according to the file path and return an fs.Stats object
fs.stat(filedir, (err, stats) => {
if (err) {
console.warn('Get files failed......');
} else {
let isFile = stats.isFile(); // files
let isDir = stats.isDirectory(); //files folder
if (isFile) {
fileList.push(path.basename(filedir, '.json'));
fileList.forEach(item => {
localJsonDb[item] = getjsonContent(item);
})
}
if (isDir) {
fileDisplay(filedir);
}
Object.keys(fakeoriginalData).map(item => {
localJsonDb[item] = fakeoriginalData[item];
})
}
})
})
………
function getjsonContent(path) {
let newpath = `./src/app/mock/json/${path}.json`;
let result = JSON.parse(fs.readFileSync(newpath));
return result;
}
………
上面这段代码做的事情,就是文件名转路径。通过调用node中的fs模块中的readdirSync和stat方法,读取文件夹内容,判断文件状态,之后将符合条件的文件push到fileList数组中,同时,通过readFileSync方法读取文件内容,并组成如下对象,供server启动服务用:
{
………
'uui-sotn_getPinterfaceByVpnId': { 'vpn-binding': [ [Object] ] },
'uui-sotn_getPnfInfo': {
'pnf-name': 'pnf1000',
'pnf-id': '79',
'in-maint': true,
'resource-version': '195',
'admin-status': 'up',
'operational-status': 'up',
'relationship-list': { relationship: [Array] }
},
'uui-sotn_getSpecificLogicalLink': {
'link-name': 'nodeId-79-ltpId-4_nodeId-78-ltpId-4',
'in-maint': false,
'link-type': 'some type',
'speed-value': 'some speed',
'resource-version': '13031',
'operational-status': 'up',
'relationship-list': { relationship: [Array] }
},
xuran_test_data: {
'esr-system-info-id': 'xuran',
'service-url': 'http://10.10.10.10:8080/',
'user-name': 'demo',
password: 'demo123456!',
'system-type': 'ONAP',
'resource-version': '18873'
}
………
}
如上例,通过配置,将json文件的文件名作为导入对象的key值,将文件内容作为导入文件的value值。经过这样配置的对象,就是可以被json-server所使用的数据了。
值得注意的是,这样的操作方式需要将原有的mock文件进行重命名,使其符合接口的规则。基本要求是:文件名=下划线连接的请求路径名。这样做有些麻烦,但是只需要修改一次,日后有新的数据修改需求就变得很方便,同时对于其他开发者来说,只需要做在json文件夹中添加符合规范的文件就可以实现mock数据功能,性价比还是可以的。
第二,本项目引入了json-server中间件,使用了router将拼接好的路径对象应用起来,搭建项目服务。将上述对象导入进来就可以实现接口的转发了。
function runServer(db) {
server.use(jsonServer.router(db));
}
第三,导入的对象的key值是用下划线连接的,但是真实请求下各路径间使用/连接的,因此,导入服务的接口需要通过一层转发才能真正达到模拟请求的目的。可以通过在mock文件夹下添加routes.js文件来配置路径转发方式。同时,使用json-server的另外一个中间件 rewriter 来对重新配置后的路径进行转发:
//导入routes已经重写路径
const customersRouters = require('./routes’);
………
function serverRewrite() {
server.use(jsonServer.rewriter(customersRouters))
}
//routes.js路径配置
…………
module.exports =
{
///////<-------------general interface--------->/////
"/api/*": "/$1",
"/*/*": "/$1_$2",
"/*/*/*": "/$1_$2_$3",
"/*/*/*/*": "/$1_$2_$3_$4",
/////////////////////////
}
经过以上的配置,一个初步可用的server服务器已经搭建起来了,这时运行npm run mock,可以发现大部分原有的接口已经运行起来了,大吉大利。
- fake数据
当然,一个前后端分离的项目是要支持前端同学在脱离后端接口支持的情况下进行独立开发的。为了使项目能够实现敏捷开发,我们还需要借助一些工具。mock.js是很常见的工具,但是对于json-server来说,似乎最佳搭配还是faker.js。faker.js是个很强大的创建mock数据的工具。虽然对中文的支持还不够好,但是由于本项目属于国际化的开源项目,因此这个工具很适合此项目的开发。
首先安装faker.js:
npm install faker
接着,创建mck/fake文件夹用于管理faker.js生成的fake数据。本项目将fake数据按功能分成了两部分。
第一部分是fake数据生成功能。
const faker = require("faker");
const _ = require("lodash");
faker.locale = "en";
module.exports = {
customer: _.times(20, function (n) {
return {
id: n,
name: faker.name.findName(),
phone: faker.phone.phoneNumber(),
address: faker.address.streetAddress(),
avatar: faker.internet.avatar()
}
}),
language: { language: faker.random.locale() }
}
另一部分是fake对象生成功能。
const fakeData = require('./fakedata.js');
module.exports = {
//Mock json
'customer_info': fakeData.customer,
'alarm_formdata_multiple': fakeData.customer,
'home': fakeData.home,
'language': fakeData.language,
}
最后一步是将生成的fake数据导入到现有的server服务中来。
// 引入fake数据对象,并合并入生成的数据对象中
const fakeoriginalData = require('./fake/mock.js'); //import datas created in fakedata.js
………..
files.forEach((filename) => {
…………….
if (isDir) {
fileDisplay(filedir);
}
Object.keys(fakeoriginalData).map(item => {
localJsonDb[item] = fakeoriginalData[item];
})
})
总言之,思路就是首先通过faker.js生成所需数据,然后将其封装成符合规范的api对象,最后引入到server.js中,并将其和本地的json文件一同处理为router可识别的路径。
通过上述三步操作,基础操作部分已经完成,一个前后端分离的json-server服务器基本搭建完成,可以撒花了。
自定义配置
之所有还有这一节,是因为在实际使用的过程中,遇到了问题,即之前提到的优化过程中出现的难点。所以需要在原有基础上再进行一些自定义的配置来解决。
第一个问题是自定义资源请求
json-server
可以自动读取规范的资源请求路径,但是,如果请求路径中存在变量,基础的功能就不能满足这个要求了。比如这种类型的请求:
createServiceType: this.baseUrl + "/uui-lcm/customers/*_*/service-subscriptions/*+*",
getCustomerresourceVersion: this.baseUrl + "/uui-lcm/customers/*_*",
getServiceTypeResourceVersion: this.baseUrl + "/uui-lcm/customers/*_*/service-subscriptions/*+*",
路径中的_和+代表变量。而上述路径中,无论是中间还是尾部都存在变量,这种路径无法被json-server自动检测,需要手动配置。因此,我们需要在routes中进行配置:
//routes.js
"/uui-lcm/serviceNumByServiceType/:customer": "/CustomersColumn",
"/uui-lcm/customers/:customer": "/getCustomerresourceVersion”,
"/uui-lcm/customers/:customer/service-subscriptions/:id": "/getServiceTypeResourceVersion",
如上所示,将特殊的路径在routes中进行配置,冒号后面是需要代理到的地址。这样就可以实现存在变量的api地址的资源转发了。
第二个问题是post put delete请求的转换
项目中存在相同url不同请求方式的接口,且参数拼接方式相同,单纯使用json-server无法满足需求。比如:
customers: this.baseUrl + "/uui-lcm/customers", /* get */
deleteCustomer: this.baseUrl + "/uui-lcm/customers", /* delete */
createCustomer: this.baseUrl + "/uui-lcm/customers/", /* put */
……………
getAllCustomers() {
return this.http.get<any>(this.url.customers);
}
createCustomer(customer, createParams) {
let url = this.url.createCustomer + customer;
return this.http.put(url, createParams);
}
deleteSelectCustomer(paramsObj) {
let url = this.url.deleteCustomer;
let params = new HttpParams({ fromObject: paramsObj });
return this.http.delete(url, { params });
}
如上述所示,/uui-lcm/customers这个地址通过请求方式和参数的不同,实现了不同的功能。json-server有特定的语法,对post、put等请求有既定的返回数据格式的规范。但是在本项目中,却需要返回既有数据,因此,需要对数据请求接口进行拦截,改造成需要的路径,同时更改这些接口的请求方式,来需要解决相同url但不同请求方式的问题:
const fs = require('fs');
const path = require('path');
const jsonServer = require('json-server');
const server = jsonServer.create();
const middlewares = jsonServer.defaults();
const customersRouters = require('./routes');
const baseUrl = "/usecaseui-server/v1";
…………
server.post(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/POST${prefix}`;
req.method = 'GET';
next();
})
server.put(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/PUT${prefix}`;
req.method = 'GET';
next();
})
server.delete(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/DELETE${prefix}`;
req.method = 'GET';
next();
})
……………
上述代码使用了json-server来拦截不同类型的服务,拦截之后改变了请求地址和请求方式。通过在地址中添加PUT、DELETE等字段,将这些请求与原有的请求隔离开,之后将请求方式改为GET,最后再在routes文件和json文件中进行配置即可满足需求。之所以这样操作,是因为对于前端开发来说,在项目的开发阶段,需要关注的只有请求参数和返回结果,换句话说,在这个阶段,无论前后端采用什么样的请求方式,都与前端关系不大,前端只需要知道是向什么接口发了什么参数并返回了什么结果就可以了,因此,这里将其他类型的请求全部转换为GET就是基于这个原因。
//routes.js
"/PUT/uui-lcm/customers/:name/service-subscriptions/:id": "/PUT_uui-lcm_customers_service-subscriptions”,
"/DELETE/uui-lcm/customers/:customer/service-subscriptions/:id": "/DELETE_uui-lcm_customers_service-subscriptions",
如此这般,就解决了项目在mock数据过程中的全部问题,一个比较完整的Mock数据系统也搭建完成了。自此,可以彻底摒弃之前两套代码的开发模式,通过一个简单的npm run mock命令来启动本地mock开发了。
最后上一遍server.js的完整代码:
const jsonServer = require('json-server');
const server = jsonServer.create();
const middlewares = jsonServer.defaults();
const customersRouters = require('./routes');
const baseUrl = "/usecaseui-server/v1";
// Set default middlewares (logger, static, cors and no-cache)
server.use(middlewares);
// Get mock data
const fs = require('fs');
const path = require('path');
let localJsonDb = {}; //import mock datas
const fakeoriginalData = require('./fake/mock.js'); //import datas created in fakedata.js
const mockFolder = './src/app/mock/json'; //mock json path folder
const filePath = path.resolve(mockFolder);
fileDisplay(filePath);
function fileDisplay(filePath) {
let fileList = [];
// Return filelist on based of filePath
const files = fs.readdirSync(filePath);
files.forEach((filename) => {
// Get filename's absolute path
let filedir = path.join(filePath, filename);
// Get the file information according to the file path and return an fs.Stats object
fs.stat(filedir, (err, stats) => {
if (err) {
console.warn('Get files failed......');
} else {
let isFile = stats.isFile(); // files
let isDir = stats.isDirectory(); //files folder
if (isFile) {
fileList.push(path.basename(filedir, '.json'));
fileList.forEach(item => {
localJsonDb[item] = getjsonContent(item);
})
}
if (isDir) {
console.warn("=====> DO NOT support mock data in folder");
fileDisplay(filedir);
}
Object.keys(fakeoriginalData).map(item => {
localJsonDb[item] = fakeoriginalData[item];
})
}
})
})
setTimeout(() => {
serverRewrite();
runServer(localJsonDb);
}, 100)
}
function getjsonContent(path) {
let newpath = `./src/app/mock/json/${path}.json`;
let result = JSON.parse(fs.readFileSync(newpath));
return result;
}
//only multi router data needs jsonServer.rewriter
function serverRewrite() {
server.use(jsonServer.rewriter(customersRouters))
}
function runServer(db) {
server.use(jsonServer.router(db));
}
server.post(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/POST${prefix}`;
req.method = 'GET';
next();
})
server.put(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/PUT${prefix}`;
req.method = 'GET';
next();
})
server.delete(`${baseUrl}/*`, (req, res, next) => {
const prefix = req.url.replace(baseUrl, "");
req.url = `${baseUrl}/DELETE${prefix}`;
req.method = 'GET';
next();
})
server.listen(3002, () => {
console.log('Mock Server is successfully running on port 3002 😁')
});
拜拜。今晚回家吃鸡🐔