从零实现 SPA 框架快速同步配置生成接口(angular2 + Easy-mock)

背景

随着 Angular, Vue, React 等 SPA 框架的普及,前后端分离的开发方式已经成为了主流,而由于前后端的并行开发,接口联调则成为了经常会出现问题的环节。

前后端接口(Interface)的调用本身实际上可看作前后端数据的调用过程,而 Http 只是实现前后端接口调用的手段而已。但前后端的接口联调工作往往会有很大一部分的时间花费在文档同步、URL 修改等繁复的机械性工作中。

本文旨在抛砖引玉,介绍我们团队内 angular 项目正在使用的接口同步思想,使用自动同步接口配置的方法弱化 Http 接口的存在。

使用 Easy-mock 书写接口文档

Easy Mock 是一个可视化,并且能快速生成模拟数据的持久化服务,具有以下特性:

  • 支持接口代理
  • 支持快捷键操作
  • 支持协同编辑
  • 支持团队项目
  • 支持 RESTful
  • 支持 Swagger | OpenAPI Specification (1.2 & 2.0 & 3.0)
  • 基于 Swagger 快速创建项目
  • 支持显示接口入参与返回值
  • 支持显示实体类
  • 支持灵活性与扩展性更高的响应式数据开发
  • 支持自定义响应配置(例:status/headers/cookies)
  • 支持 Mock.js 语法
  • 支持 restc 方式的接口预览

Easy Mock 可通过 Swagger 同步接口,并可使用 Mock.js 语法生成丰富的接口数据供前端调用,满足了接口文档和 mock 服务器所需的全部功能。

使用配置接口的基类 EntityClass

前后端接口(Interface)的调用本身实际上可看作前后端数据的调用过程,而 http 只是实现前后端接口调用的手段而已,所以我们前端团队推广 Easy-mock 后,将 Easy-mock 的一个个接口视为一个个的 Angular Service,实现了名为 EntityClass 的 Service 基类,EntityClass 代码如下:

    
    /* 
    export const environment = {
    production: false,
    baseUrl: 'https://www.easy-mock.com/mock/{__projectId}'};
    */
import {Component, Injectable} from '@angular/core';
import {EntityInterface} from './entity.interface';
import {Observable} from 'rxjs/Observable';
import {HttpClient} from '@angular/common/http';
import 'rxjs/add/operator/map';
import {environment} from "../environments/environment";

@Injectable()
export class EntityClass implements EntityInterface {
    subject: any;

    private httpMethod(params: { [param: string]: string | string[]; }): Observable<any> {
        const requestUrl = this.prefixUrl(environment.baseUrl + this['url'], params);
        switch (this['method']) {
            case 'post':
                return this.http.post(requestUrl, params, {params});
            case 'patch':
                return this.http.patch(requestUrl, params, {params});
            case 'put':
                return this.http.put(requestUrl, params, {params});
            case 'delete':
                return this.http.delete(requestUrl, {params});
            default:
                return this.http.get(requestUrl, {params});
        }
    }

    constructor(public http: HttpClient) {
    }

    private prefixUrl(url: any, params: Object): string {
        // 可附加部分全局参数
        for (const name in params) {
            if (((typeof params[name]) === 'string' || (typeof params[name]) === 'number') /*&& params[name] !== ''*/) {
                url = url.replace(new RegExp('{' + name + '}', 'gm'), params[name]);
                // url = url.replace('{' + name + '}', params[name]);
            }
        }
        url = url.replace(new RegExp('{__projectId}', 'gm'), this['__projectId']);
        console.log(url);
        if (url.indexOf('{') >= 0) {
            console.log(params);
            console.log(url);
            throw new Error('params is not resolve');
        }
        const urlArray = url.split('?');
        urlArray[0] += '';
        return urlArray.join('?');
    }

    private responseResolver(response: any): any {
        return response;
    }

    sendRequest(component: any = undefined, params: any, cb = (data: any, err: Error = undefined) => {
    }, componentP = '') {
        this.subject = this.httpMethod(params)
            .map(this.responseResolver)
            .map((resp) => {
                return resp;
            }).subscribe((resp) => {
                if (componentP && component) {
                    component[componentP] = resp.data;
                }
                cb(resp.data);
                this.subject.unsubscribe();
            }, (err) => {
                if (err.status === 0) {
                    this.getData(component, params, cb, componentP);
                } else {
                    cb({}, err);
                }
            }, () => {
            });
    }

    getData(component: Component, params: Object = {}, cb = (data: Object, err: Error = undefined) => {
    }, componentP = 'data') {
        this.sendRequest(component, params, cb, componentP);
    }

    sendData(component: Component, params: Object = {}, cb = (data: Object, err: Error = undefined) => {
    }) {
        this.sendRequest(component, params, cb, '');
    }
}



EntityClass 主要有以下几个方法

prefixUrl(url: any, params: Object): string

负责 URL 内 {param} 格式的字符串被对应的 param 替换 ,如有 URL 为 http://blog.dongsj.cn/user/{id} 和 params 为 {id:1} ,则会返回值 http://blog.dongsj.cn/user/1

private responseResolver(response: any): any

负责 response 的处理,可在子类进行重写

sendRequest(component: any = undefined, params: any, cb = (data: any, err: Error = undefined) => {}, componentP = '')

负责通用的 request 的发送和 response 的处理,并将 response 的数据自动存储到实际类型为 Component 的 component 的 componentP 字段内

getData(component: Component, params: Object = {}, cb = (data: Object, err: Error = undefined)

在读数据的场景使用,实质为带了部分参数的 sendRequest 方法,会降 response 赋值 component.data 内,具体使用会在日后其他文章內说明

sendData(component: Component, params: Object = {}, cb = (data: Object, err: Error = undefined)

在写数据的场景使用,实质为带了部分参数的 sendRequest 方法,具体使用会在日后其他文章內说明

使用注解和 EntityClass 生成接口

上文已经实现了 Angular 内的 Service 基类 EntityClass,现只需对 EntityClass 配置 url,preUrl,method 即可生成对应的接口,并在需要时进行接口注入并使用。我们书写了注解 EntityDecorator 对 Service 配置上述属性并继承 EntityClass,即可实现接口 Service 的配置化生成,EntityDecorator 代码如下:


import {EntityDecoratorOptions} from './entity.interface';

export interface EntityDecoratorOptions {
    url: string,
    method: string,
    serviceName: string,
    __projectId: string
}
export function EntityDecorator (options: EntityDecoratorOptions) {
  return (target: Function) => {
    if (options.url[options.url.length - 1] === '/') {
      const url: any = options.url.split('');
      url.pop();
      options.url = url.join('');
    }
    Object.assign(target.prototype, options);
  };
}


该注解需serviceName, url, method, preUrl, __projectId五个参数,其中preUrl在实际应用时应结合 Angular 的 envirement 进行使用,此处主要为
如此使用 EntityDecorator + EntityClass 生成一个接口的 Service:

import {Injectable} from '@angular/core';
import {EntityClass} from '../../entity.class';
import {EntityDecorator} from '../../entity.decorator';

@Injectable()
@EntityDecorator({
  serviceName: 'Get',
  url: '/',
  method: 'get',
  __projectId: '5aba151166dc89079e232310' //主要为多项目时区分url使用
})
export class DemoProjectGetDataService extends EntityClass {

}

至此您可能会问,如此不是每次修改 Easy-mock 内的接口,还是需要修改对应 EntityService 内注解的参数吗?但是现在每个接口都是独立的一个 EntityService ,可以快速的重写 EntityService.responseResolver 方法实现对特定接口的全局统一处理,或是使用其他 Service 结合 EntityService 实现面向对象,最重要的是 EntityService 内注解的参数的修改可从 Easy-mock 内同步,降低人工修改成本。

从 Easy-mock 内同步 EntityService 注解参数

上文注解内需要serviceName, url, method, __projectId四个参数,Easy-mock 提供了 URL, method 两个参数,__projectId 则对应 Easy-mock内的项目id,serviceName 可从 URL + method 进行生成,但当 URL 或 method 改变时 EntityService 的名称也会跟着改变,所以我们对 Easy-mock(基于v1.4.0) 增加了 serviceName 属性以对应注解内的 serviceName。

github:https://github.com/supperdsj/easy-mock/tree/master

之后我们只需编写脚本 createService.js 通过 Http 请求从 Easy-mock 读取数据并生成 EntityService ,并生成了 RequestsDataService 管理所有 EntityService 并将全部生成的 Service 放在数组 RequestsDataServicesDepends 内简化依赖注入,代码如下:


const axios = require('axios');
const fs = require('fs');
const child_process = require('child_process');
const config = require(process.cwd() + '/createServiceConfig.json');
const projects = config.projects;
const path = process.cwd() + '/' + config.path + '/services';
const username = config.username;
const password = config.password;
const mockServer = config.mockServer;

const serviceArray = [];

const upperCaserForFirstLetter = (str) => {
    return str.substring(0, 1).toUpperCase() + str.substring(1);
};

const lowerCaserForFirstLetter = (str) => {
    return str.substring(0, 1).toLowerCase() + str.substring(1);
};

const getToken = async (username, password) => {
    return (await axios.post(`${mockServer}/api/u/login`, {
        name: username,
        password: password
    })).data.data
};
const getMocksByProject = async (projectId) => {
    return (await axios.get(`${mockServer}/api/mock?project_id=${projectId}&page_size=2000&page_index=1`)).data.data
};

const saveService = (mock, project) => {
    // console.log();
    mock.serviceName = mock.serviceName||((mock.method + mock.url).split('/').filter(str => str.indexOf(':') < 0 && str.trim() !== '').map(upperCaserForFirstLetter)).join('');
    // console.log(mock);
    const temp =
        `/* 
    ${JSON.stringify(mock)}
*/

import {Injectable} from '@angular/core';
import {EntityClass} from '../../entity.class';
import {EntityDecorator} from '../../entity.decorator';

@Injectable()
@EntityDecorator({
  url: '${mock.url}',
  method: '${mock.method}',
  serviceName: '${upperCaserForFirstLetter(mock.serviceName)}',
  preUrl: '${config.baseUrl}${project.url}',
  __projectId: '${project._id}'
})
export class ${upperCaserForFirstLetter(project.name)}${upperCaserForFirstLetter(mock.serviceName)}DataService extends EntityClass {

}
`;
    const fileName = `${path}/${project.name}/${project.name}-${upperCaserForFirstLetter(mock.serviceName)}-data-service.ts`;
    fs.appendFileSync(fileName, temp);
    console.log(`${mock.serviceName} saved`);
    serviceArray.push({
        serviceName: `${upperCaserForFirstLetter(project.name)}${upperCaserForFirstLetter(mock.serviceName)}DataService`,
        filePath: `./${project.name}/${project.name}-${upperCaserForFirstLetter(mock.serviceName)}-data-service`
    });
};
const buildServices = async () => {
    let userInfo = await getToken(username, password);
    axios.defaults.headers.common['Authorization'] = `Bearer ${userInfo.token}`;
    child_process.execSync(`rm -rf ${path}`);
    fs.mkdirSync(path);
    for (let project of projects) {
        fs.mkdirSync(`${path}/${project.name}`);
        let resp = await getMocksByProject(project.id);
        for (let mock of resp.mocks) {
            saveService(mock, Object.assign(resp.project, {name: project.name, title: project.title}));
        }
    }
};

const buildModule = (serviceArray) => {
        let serviceConstructors = serviceArray.map(s => `private ${lowerCaserForFirstLetter(s.serviceName)}: ${s.serviceName}`).join(',\n');
        let serviceImports = serviceArray.map(s => `import {${s.serviceName}} from '${s.filePath}';`).join('\n');
        let moduleProivdes = serviceArray.map(s => s.serviceName).join(',\n');
        let requestsServiceArrayConfig = serviceArray.map(s => `{name: '${lowerCaserForFirstLetter(s.serviceName)}', service: ${lowerCaserForFirstLetter(s.serviceName)}}`);
        let serviceTemp = `import {Injectable} from '@angular/core';
${serviceImports}
@Injectable()
export class RequestsDataService {
  requestsServiceArray = [];
  constructor(${serviceConstructors}) {
    this.requestsServiceArray = [${requestsServiceArrayConfig.join(',\n')}];
  }
  
  getServiceByName(serviceName: string) {
    const service = this.requestsServiceArray.find(s => s.name.toLocaleLowerCase() === serviceName.toLowerCase());
    console.log(service);
    if (!service) {
      throw new Error('Request service name not found');
    } else {
      return service.service;
    }
  }
}
export let RequestsDataServicesDepends = [${moduleProivdes},\nRequestsDataService];
`;
        const serviceFileName = `${path}/requests-data-service.ts`;
        fs.appendFileSync(serviceFileName, serviceTemp);
    }
;
buildServices().then(() => {
    console.log('\nService build success');
    buildModule(serviceArray);
}).catch((e) => {
    console.log(e);
});

上述代码对应配置文件 createServiceConfig.json 如下:

{
  "username": "emnsl",
  "password": "111111",
  "path": "src/entityService",
  "mockServer": "https://www.easy-mock.com",
  "projects": [
    {
      "title": "demo",
      "name": "demoProject",
      "id": "5aba151166dc89079e232310"
    }
  ]
}

之后只需配置 createServiceConfig.json 内的各个参数执行 createService.js 即可从 Easy-mock 读取接口配置并生成 EntityService 供 angular 使用。

范例

github:https://github.com/supperdsj/easy-mock-service-loader

example 内的范例项目使用 angular-cli 搭建,使用时拷贝 dist 内文件到项目目录下,修改 createServiceConfig.json 内的各配置对应 Easy-mock 项目的配置安装依赖后即可执行 npm run start。

结语

本文主要基于 Angular 实现该思想,欢迎实现其他语言的同类工具来我的博客(http://blog.dongsj.cn) 留言。
博客新开欢迎访问留言,近期会更新使用 Component 类继承和 EntityService 管理端页面的开发。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 你说你想出去走走 于是在一个晴天午后 你背起了行囊 哪儿是你的目的地呢 是浩瀚的海洋 无垠的沙漠 还是那未知的森林...
    酾藇阅读 421评论 2 4
  • 人生最重要的的是什么?我想可能就是健康,家庭和财富了。但我觉得更要有一种雨过天晴的人生态度。 要想过得简单安逸,有...
    木紫薇阅读 142评论 2 1
  • 从冰山模型中我们知道,人格作为人发展的重要因素,在冰山以下的部分,也是潜藏的最深,最难以测评,最难以发掘的,甚至自...
    贺华文PCC教练阅读 2,075评论 0 4