# ✪ 关于ppfly
#### ppfly是一个基于Typescript和 [egg.js](https://github.com/eggjs) 的轻量级框架，目的是实现高度统一的开发规范、降低开发难度、大幅度提高开发效率，让后端开发人员可以更加专注业务逻辑的实现，而不是把时间浪费在写重复代码、写API文档之类的事情。
-   使用装饰器 @action 完成路由设置、参数验证
-   完善数据验证和角色验证流程
-   约定了统一消息交换格式，包括成功消息、失败消息、分页数据格式
-   统一异常处理流程，实现不依赖任何插件
-   自动生成API文档（运行时动态生成，自带一个比swagger更强大的API文档浏览客户端）
-   分布式缓存、MQ、文件图片上传处理等
-   根据模型定义自动生成模型的接口、参数验证规则（需要配合ppfly的vscode插件）

# ✪ 目录结构
``` js
app
├── config (可选)
├── controller (egg目录规范，保存控制器)
├── model (egg目录规范，保存数据库数据模型)
├── permission (可选，保存权限配置)
├── rule  (必须，保存参数验证规则)
└── service (egg目录规范)
```

# ✪ 请求的处理流程
``` bash
┌───────────┐          ┌──────────────┐          ┌────────┐          ┌────────┐
│ 客户端请求 │  =====>  │ Interceptors │  =====>  │ action │  =====>  │ Result │ 
└───────────┘          └──────────────┘          └────────┘          └────────┘
```
-   Interceptor(拦截器): 主要实现参数验证（Validator）、权限验证（RoleAuthorize）
-   Result(结果渲染器): 输出结果，框架实现的主要是 ：ActionResult、 ViewResult、 JsonResult、XmlResult、 FileResult、 RedirectResult、 StatusResult
-   action 为真正的业务逻辑处理方法    

# ✪ 返回消息格式

``` js
public static readonly SUCCESS_CODE = 200;
public static readonly ERROR_CODE = -1000;

export interface IResult {
    /**
     * 错误代码:成功为正数（默认200），失败为负数（默认-1000）
     */
    code: number;

    /**
     * 操作是否成功
     */
    success: boolean;

    /**
     * 具体的消息描述
     */
    msg: string;

    /**
     * 返回数据
     */
    data?: any;
}
```
-   成功执行：Result.success(msg: string, data?: any)
-   失败执行：Result.fail(msg: string, code: number = Result.ERROR_CODE, data?: any)
-   异常执行：Result.error(err: BaseError, data?: any)

#### 在ppfly的世界里，服务端在正常响应情况下均返回HTTP CODE 200，所以前端判断是否执行成功、有无出现异常要解析HTTP返回的数据内容。通常只会出现3种情况：
1. {code: 200, success: true, msg: ...}
2. {code: -XXX, success: false, msg: ...}
3. 客户端需要的数据内容

#### 前端判断是否异常，可以借鉴以下代码:
```js
import axios from 'axios';
const service = axios.create({
    baseURL: API_URL,
    timeout: 15000
})
service.interceptors.response.use(
    response => {
        store.commit(LOADING_SET_STATUS, false);
        Toast.clear();
        const { data } = response;
        if (typeof data === 'object'
            && typeof data.success === 'boolean'
            && typeof data.msg === 'string') {
            if (!data.success) {
                // 服务器返回错误信息
                Toast.fail(data.msg);
                if (data.type === 'error.timeout') {
                    router.replace({ path: '/login', query: { timeout: true } });
                    return;
                }
                // 返回空的数据
                return;
            }
        }
        return response.data;
    },
    error => {
        store.commit(LOADING_SET_STATUS, false);
        // 服务器无法响应，返回空的数据
        Toast.fail("服务器暂时无法响应您的请求，请稍后重试。");
        return;
    }
)
```
#### 框架声明的异常类型主要有:

| 异常名称          | code    |  附加数据                                                       |
| ---------------- | ------- | ----------------------------------------------------------------|
| `ActionError`    | -1000   | {type: 'error.action'}                                          |
| `APIError`       | -----   | {type: 'error.api'}                                             |
| `ValidateError`  | -10086  | {type: 'error.validate', errors: [{ field:'', message:'' }] }   |
| `TimeoutError`   | -10010  | {type: 'error.timeout'}                                         |
| `AccessError`    | -10020  | {type: 'error.access'}                                          |


# ✪ 分页数据格式

``` js
/**
 * 分页信息约定
 */
export interface IPageInfo {
    /**
     * 页面ID，第一页值为1
     */
    pageId: number;

    /**
     * 每页数据数量
     */
    pageSize: number;

    /**
     * 数据总数
     */
    dataCount: number;

    /**
     *  游标信息
     */
    cursor?: string | number;
}

/**
 * 分页数据约定
 */
export interface IPagingData<TData> {
    /**
     * 分页信息
     */
    pageInfo: IPageInfo;

    /**
     * 分页数据
     */
    data: Array<TData>;
}
```
#### 只需要一句代码即可实现分页数据返回(目前只支持MongoDB)

```js
import PagingService from 'ppfly/service/paging';

export default class UserService extends Service {
    public async searchUser(filter: any, pageInfo: any, orderBy: any): IPagingData {
        return PagingService.getPagingData(this.app.model.User, filter, pageInfo, orderBy);
    }
}
```
#### 注意: 框架为了优化性能，只会在第一页(pageInfo.pageId为1,且pageInfo.dataCount为0)的情况下才会查询数据总数并设置pageInfo.dataCount,其余情况下pageInfo.dataCount为前端传过来的值或者0。

# ✪ @action 和 @role 修饰符
#### 通过使用@action修饰符可以实现路由注册、指定参数验证规则（自动注入）、指定使用的拦截器和渲染器。
``` js
// @controller主要是为了API文档生成，没有其他用途
@controller('home', '首页')
export default class HomeController extends Controller {

    @role(['权限1', '权限2'])
    @action({
        methods: [HttpMethod.GET, HttpMethod.POST],
        name: '首页',
        path: '/home/index',
        params:{
            id: ...
        }
    })
    public async home({id}) {
        // 这里可以不返回数据，默认返回 ActionResult.create(Result.success(`${action}执行成功`))
        // 也可以直接返回数据或者 IActionResult
        // return StatusResult.create(404)
        // return RedirectResult.create('/404.html')
        // return XmlResult.create(...);
        // 业务逻辑出现错误可以直接抛出 throw new ActionError('....')

        return 'hello';  // 等同：return ActionResult.create('hello'); 
    }
}
```
#### 需要在 app/router.ts 中注册

```js
export default (app: Application) => {
  // 注册路由
  Action.registerRouter(app);
};
```

####  @action 修饰符的参数 ActionConfig定义如下:
``` js
/**
 * Action配置信息
 */
export default interface ActionConfig {
    /**
     * Action的名称
     */
    name: string;

    /**
     * 路由地址
     */
    path: string;

    /**
     * HTTP 方法，可以是数组也可以是单个值, 默认为 HttpMethod.POST
     */
    methods?: string | string[];

    /**
     * Action 方法参数注入;
     * 如果值类型为字符串数组，则通过全局的规则验证;
     * 如果值类型为对象，则根据对象中每个字段的配置验证;
     */
    params?: string[] | object;

    /**
     * Action 结果渲染器, 默认为 ActionType.action
     */
    result?: string | IActionResult;

    /**
     * Action 请求拦截器，默认为 ['validator','role']
     */
    interceptors?: string[] | Interceptor[];

    /**
     *  指定Action返回的数据模型
     */
    model?: string;

    /**
     * 使用中间件
     */
    middleware?: any;

    /**
     * 自定义附加数据
     */
    data?: object;
}
```

# ✪ 统一的异常处理流程
首先在 /app.ts 中注册
``` js
export default (app: Application) => {
    app.beforeStart(async () => {
        ....
    });

    // 配置异常处理
    Action.handleError(app, new DefaultErrorHandler());
};
```
``` js
// 代码：ppfly/handle/error
// 也可以自己实现 IErrorHandler
export default class DefaultErrorHandler implements IErrorHandler {
    public async handle(ctx, err) {
        let data = {};
        if (err instanceof ValidateError) {
            data = Result.error(err, { errors: err.errors });
        }
        else if (err instanceof APIError) {
            data = Result.error(err);
        }
        else if (err instanceof ActionError) {
            data = Result.error(err);
        }
        else if (err instanceof TimeoutError) {
            data = Result.error(err);
        }
        else if (err instanceof AccessError) {
            data = Result.error(err);
        }
        else {
            ctx.logger.error('未知异常', err);
            data = Result.fail('服务端暂时无法处理您的请求。', -1024);
        }
        return data;
    }
};
```

# ✪ 参数验证
``` js
export default (app: Application) => {
    app.beforeStart(async () => {
       ...
    });

    // 配置参数验证器
    Validator.service = new ParameterValidator();
};
```
- 在app.ts注册验证器后就可以在@action修饰器中指定验证规则
- 目前框架只有一个ValidatorService实现：ParameterValidator，部分规则基于[validator.js](https://github.com/chriso/validator.js)
- 验证器支持数组的结构验证，也支持嵌套验证
- 验证有错误就会抛 ValidateError (ppfly/error/validate)

``` js
 @action({
        ...
        params: {
            id: {
                type: 'string',
                min: 24,
                max: 24,
                memo: 'ID',
                message: '参数ID验证失败'
            },
            images: {
                type: 'array',
                min: 1,
                required: false,
                schema: {
                    url: {
                        type: 'string'
                        ...
                    }
                }
            },
            xxx: {
                type: {
                    type: 'number',
                    ...
                }
            }
        }
    })
public async test({ id, images, xxx }){
    console.log( id, images, xxx)
}
```
#### 目前验证规则支持以下参数：
参数名称               | 说明
--------------------- | -------------------------------
**type**              | 值类型，必填
**memo**              | 描述（备忘），选填
**required**          | 是否必须，选填, 默认值为 true
**message**           | 错误消息，选填
**min**               | 最小值，当类型为字符串时代表最小长度（同len属性），当类型为数组时代表数组最小长度
**max**               | 最大值

#### 当类型为枚举(enum)时，支持以下扩展参数：
参数名称               | 说明
--------------------- | -------------------------------
**value**             | 枚举值类型 
**values**            | 枚举可使用的值

#### 当类型为数组(array)时，支持以下扩展参数：
参数名称               | 说明
--------------------- | -------------------------------
**schema**            | 数组内的元素结构规则描述

#### 其中参数type支持的类型有：

``` js
export enum ParamType {
    NUMBER = 'number',
    INTEGER = 'int',
    FLOAT = 'float',
    STRING = 'string',
    DATE = 'date',
    BOOLEAN = 'bool',
    ARRAY = 'array',
    ENUM = 'enum',
    EMAIL = 'email',
    URL = 'url',
    HASH = 'hash',
    JSON = 'json',
    JWT = 'jwt',
    PHONE = 'phone',
    OBJECT_ID = 'objectId'
}
```

# ✪ 角色验证
``` js
export default (app: Application) => {
    app.beforeStart(async () => {
       ...
    });

   // 配置全局角色验证
    RoleAuthorize.service = new RoleValidator();
};
```
``` js
export default class RoleValidator implements RoleService {
    async validate(target: any, metadata: ActionMetaData, permissions: string[]) {
        // permissions => 'XX权限'
         if (!permissions || permissions.length === 0) {
            return;
        }
        const { ctx, app } = target;
        const token = ctx.request.header.authorization;
        const session: IUserSession = await ctx.service.session.getSession(token);
         if (!session) {
            throw new TimeoutError('令牌过期,请重新登录。');
        }
        const group = await ctx.service.group.getGroupByName(session.group);
        if(!group.hasPermission(permissions)){
            throw new AccessError('执行[' + metadata.config.name + ']无权限.');
        }
        // 保存全局
        ctx.user = session;
    }
}
```
``` js
@role(['XX权限'])
public async test(){
    console.log(this.ctx.user);
}
```

# ✪ API文档生成
``` js
import ApiDoc from 'ppfly/api';

@action({
    name: 'API文档生成',
    path: '/dev/api',
})
public async api(params) {
    const configs = await this.service.config.getAllConfig();

    const data: any = ApiDoc.build(this.ctx, {
        info: {
            title: 'XXX商城',
            description: 'XXX商城API文档',
            version: '1.0.0',
            author: 'XXX',
            copyright: '2018 © 浙江XXX电子商务有限公司',
            host: this.ctx.request.headers.host,
        },
        markdowns: [
            { name: 'readme', path: '/public/README.md' },
            { name: '中文说明', path: '/public/中文示例.md' },
        ],
        configs
    });

    return data;
}
```

# ✪ 分布式缓存
```js
import CacheService, { RedisProvider } from 'ppfly/service/cache';

export default (app: Application) => {
    app.beforeStart(async () => {
        ...
        await CacheService.init(new RedisProvider(app.config.redis.client));
    });
};
```
#### 演示代码:
```js
import CacheService, { IDataProvider } from 'ppfly/service/cache';

export default class ConfigService extends Service implements IDataProvider {
    private readonly cache_key = 'app_config';

    public async fetchData(): Promise<object> {
        this.app.logger.info('config 数据刷新。');
        return await this.ctx.model.Config.find({});
    }

    /**
     * 获得所有配置
     */
    public async getAllConfig() {
        return CacheService.getData(this.cache_key, this);
    }

    /**
     * 更新缓存
     */
    public async updateConfig() {
        CacheService.update(this.cache_key);
    }
}
```