egg+typescript搭建后端项目

5,071 阅读6分钟

第一个egg项目,也是第一个自己架构的egg+typescript后端开发框架。

2019这一年,逐渐从前端慢慢过渡到后端,其实不是转后端,更想向全栈出发,因为我觉得学习更多的技术,才能有更广的思维。 在公司一开始接触的后端是koa,写了一段时间,自己封装了koa+装饰器的版本(自动识别路由),再后来慢慢接入egg+ts,于是就有了这个脚手架。

项目地址

github.com/CB-ysx/egg-…

功能

  • 支持自动配置路由(根据目录路径以及装饰器注入)
  • 支持多种参数拦截过滤
  • 一键生成路由和service文件
  • 支持多数据库model配置(自动生成x.d.ts文件)
  • 支持多redis配置
  • 支持路径别名,拒绝长长的../../../引入

项目目录结构

egg-ts-base
├─.autod.conf.js
├─.dockerignore
├─.editorconfig
├─.travis.yml
├─Jenkinsfile					// Jenkinsfile构建文件
├─README.md
├─appveyor.yml
├─package.json
├─plopfile.js					// 用于生成文件
├─tsconfig.json
├─tshelper.js					// 自动生成数据库对应model
├─tslint.json
├─example					// 测试用的数据库
|    ├─egg_test.sql
|    └egg_test2.sql
├─docker					// docker文件
|   └Dockerfile.api.prod
├─dev-scripts					// 开发用的脚本,主要用来生成文件
|      ├─plop-templates
|      |       ├─util.js
|      |       ├─service			// 生成service文件
|      |       |    ├─index.hbs
|      |       |    └prompt.js
|      |       ├─router				// 生成路由文件
|      |       |   ├─index.hbs
|      |       |   └prompt.js
├─config					// 项目配置文件
|   ├─config.default.ts				// 默认配置
|   ├─config.local.ts				// 本地配置
|   ├─config.prod.ts				// 生产环境配置
|   └plugin.ts					// 插件配置
├─app
|  ├─router.ts					// 入口
|  ├─util					// 存放一些工具
|  |  ├─error.ts				// 统一管理错误信息
|  |  ├─redisKey.ts				// 统一管理rediskey
|  |  └sessionKey.ts				// 统一管理cookiekey
|  ├─service					// 存放service
|  |    ├─test
|  |    |  └test.ts
|  |    ├─lib
|  |    |  └Oss.ts
|  ├─public
|  ├─model					// 数据库模型
|  |   ├─test2
|  |   |   ├─Admin.ts
|  |   |   └User.ts
|  |   ├─test
|  |   |  └User.ts
|  ├─middleware					// 中间件
|  |     └response.ts
|  ├─lib					// 放一些第三方包之类的
|  |  ├─auth
|  |  |  └authUser.ts
|  |  ├─aRouter					// 路由识别的代码在这里面
|  |  |    ├─index.ts
|  |  |    └install.ts
|  ├─extend					// 扩展
|  |   ├─application.ts
|  |   ├─context.ts
|  |   └helper.ts
|  ├─controller					// 路由
|  |     ├─index.ts
|  |     ├─example
|  |     |    ├─index.ts
|  |     |    └test.ts
|  ├─base					// 存放一些基础类
|  |  ├─baseController.ts			// 如果要能自动识别路由,需要继承该类
|  |  └baseService.ts

自动识别配置路由功能

这里利用了装饰器,不明白装饰器的可以看下我另一篇文章:javascript装饰器的使用

文件在:github.com/CB-ysx/egg-…

主要看:

ARouter:				// 入口,用户引入app以及做一些配置
AController:				// 装饰器,使用这个的类才会自动配置路由
POST、GET、PUT、DEL、PATCH、ALL		// 各种路由请求方法装饰器
ARouterHelper类				// 前面几个只是声明,这个类才是处理那些数据自动生成路由

先来看看ARouter函数:

/**
 * 抛出hwrouter,在router.ts中直接使用ARouter(app);即可完成自动注入路由
 * @param app application
 * @param options 参数,目前只有prefix,就是所有路由的前缀
 */
export function ARouter(app: Application, options?: {prefix?: string}) {
    const { router } = app; // 获取router
    if (options && options.prefix) {
        router.prefix(options.prefix); // 配置路由的前缀
    }
    aRouterHelper.injectRouter(router); // 注入路由
}

aRouterHelper类:

/**
 * 路由注入
 */
class ARouterHelper {
    /**
     * 临时存放controller以及路由
     */
    controllers: {
        [key: string]: {
        prefix?: string, // 前缀
        target?: any, // 对应的class
        routers: Array<{ // controller下的路由
            handler: string, // 方法名
            path: string, // 路由路径
            method: RequestMethods // 请求方法
        }>
    }} = {};

    /**
     * 注入路由
     * @param router egg的路由
     */
    public injectRouter(router: Router) {
        const keys = Object.keys(this.controllers);
        keys.forEach(key => {
            const controller = this.controllers[key];
            controller.routers.forEach(r => {
                // 以前的写法是router.get('/xxx', xxx, controller.xxx.xxx);
                // 这里直接批量注入,controller.prefix + r.path拼接公共前缀于路由路径
                router[r.method](controller.prefix + r.path, async (ctx: Context) => {
                    // 得到class实例
                    const instance = new controller.target(ctx);
                    // 获取class中使用的装饰器中间件
                    const middlewares = controller.target.prototype._middlewares;
                    if (middlewares) {
                        // all是绑定在class上的,也就是下面所有的方法都需先经过all中间件
                        const all = middlewares.all;
                        for (let i = 0; i < all.length; ++i) {
                            const func = all[i];
                            await func(ctx);
                        }
                        // 这是方法自带的中间件
                        const self = middlewares[r.handler] || [];
                        for (let i = 0; i < self.length; ++i) {
                            const func = self[i];
                            await func(ctx);
                        }
                    }
                    // 经过了所有中间件,最后才真正执行调用的方法
                    await instance[r.handler]();
                });
            });
        });
    }
}

可以看到上面注入是通过aRouterHelper中的controllers来配置的,那么controllers的数据哪里来的呢?其实就是先new一个ARouterHelper实例,然后在AController、GET、POST等装饰器实现中,把数据存入controllers中。其中AController用在class上,表示class需要作为路由,GET等方法用在class中的方法上,表示这个方法需要用作路由入口。

// 先创建一个helper实例
const aRouterHelper = new ARouterHelper();

/**
 * controller装饰器
 * @param prefix 前缀
 */
function AController (prefix: string) {
    prefix = prefix ? prefix.replace(/\/+$/g, '') : '/';
    if (prefix === '/') {
        prefix = '';
    }
    return (target: any) => {
        // 获取class名
        const key = target.aRouterGetName(); // class继承自BaseController,里面就有提供aRouterGetName方法,用于获取类名
        if (!aRouterHelper.controllers[key]) {
            aRouterHelper.controllers[key] = {
                target,
                prefix,
                routers: []
            };
        } else {
            aRouterHelper.controllers[key].target = target; // target为使用装饰器的类。
            aRouterHelper.controllers[key].prefix = prefix;
        }
    };
}

/**
 * 路由装饰器
 * @param path 路径
 * @param method 请求方法(get,post等)
 */
function request(path: string, method: RequestMethods) {
    // 装饰器作用于函数上,会多出几个参数
    return function (target: any, value: any, des: PropertyDescriptor & ThisType<any>) {
        const key = target.constructor.toString().split(' ')[1];
        if (!aRouterHelper.controllers[key]) {
            aRouterHelper.controllers[key] = {
                routers: []
            };
        }
        aRouterHelper.controllers[key].routers.push({
            handler: value, // 所作用的方法名,暂存起来,用于后面执行所有中间件后调用
            path, // 设置的路由路径
            method // 请求方法(get、post等)
        });
    };
}

function POST (path: string) {
    return request(path, RequestMethods.POST);
}

上面列出了自动生成路由的代码思路,那么自动生成路由完成了,那中间件怎么使用呢?这里也是利用装饰器,把中间件暂存起来,当请求相应的路由时,先执行中间件,最后再执行目标方法,可参考上面injectRouter方法的实现。那么问题来了,middlewares从哪来的?这里我写了一个install方法,使用这个方法可以把装饰器对应的处理绑定到middlewares上。

如果装饰器作用于class上,则绑定到middlewares.all上,表示class内的所有路由都需要先经过该中间件处理,如果装饰器作用于class中的函数上,则会绑定到相应的class上,即middlewares[r.handler],r.handler就是函数名。

install实现:

/**
 * 封装路由中间件装饰器注入,支持class和methods
 */
export default (target: any, value: any, des: PropertyDescriptor & ThisType<any> | undefined, fn: Function) => {
    // 没有value,说明是作用于class
    if (value === undefined) {
        const middlewares = target.prototype._middlewares;
        if (!middlewares) {
            target.prototype._middlewares = { all: [ fn ] };
        } else {
            target.prototype._middlewares.all.push(fn);
        }
    } else {
        const source = target.constructor.prototype;
        if (!source._middlewares) {
            source._middlewares = { all: [] };
        }
        const middlewares = source._middlewares;
        if (middlewares[value]) {
            middlewares[value].push(fn);
        } else {
            middlewares[value] = [ fn ];
        }
    }
};

install使用:

/**
 * 用于过滤header参数,并挂在context的filterHeaders上
 */
function Headers (opt: {[key: string]: Function[]}) {
    return (target: any, value?: any, des?: PropertyDescriptor & ThisType<any> | undefined) => {
        return install(target, value, des, async (ctx: Context) => {
            ctx.filterHeaders = getValue(ctx.headers, opt);
        });
    };
}

// 基本新增中间件都是这样的写法
// XXXXXX就是装饰器名称
function XXXXXX (opt: {[key: string]: Function[]}) {
    return (target: any, value?: any, des?: PropertyDescriptor & ThisType<any> | undefined) => {
        return install(target, value, des, async (ctx: Context) => {
            // ...自己的处理
        });
    };
}

// 再来一个权限验证中间件例子
const auth = async (ctx: Context) => {
    const authorizeHeader = ctx.get('Authorization');
    if (!authorizeHeader) {
        throw ctx.customError.USER.UNAUTHORIZED;
    }
    const token = authorizeHeader.split(' ').pop();
    if (!token) {
        throw ctx.customError.USER.UNAUTHORIZED;
    }
    ctx.jwtInfo = ctx.helper.jwtVerify(token, ctx.app.config.jwtSecret, ctx.customError.USER.UNAUTHORIZED);
    if (!ctx.jwtInfo || !ctx.jwtInfo.userInfo) {
        throw ctx.customError.USER.UNAUTHORIZED;
    }
};

export default function (allowNull: boolean = false) {
    return (target: any, value?: any, des?: PropertyDescriptor & ThisType<any> | undefined) => {
        return install(target, value, des, async (ctx: Context) => {
            try {
                await auth(ctx); // 先进行权限验证,不通过会报错
            } catch (e) {
                if (allowNull) { // 如果运行验证不通过,则设置userInfo为null,并且不再报错(这种需求用于部分接口在有用户身份的时候和没身份的时候会返回不同的信息)
                    ctx.jwtInfo = {
                        userInfo: null
                    };
                } else {
                    throw e;
                }
            }
        });
    };
}

// 使用
@GET('/auth')
@authUser() // 验证权限,失败直接抛错误
public async auth() {
    const {
        service: { test },
        jwtInfo: { userInfo } // 权限验证成功,这里就有用户信息
    } = this.ctx;
    this.returnSuccess(await test.test.showData());
}

@GET('/auth2')
@authUser(true) // 验证权限,失败可跳过
public async auth2() {
    const {
        service: { test },
        jwtInfo: { userInfo }  // 验证成功有信息,否则为null
    } = this.ctx;
    this.returnSuccess(await test.test.showData());
}

多数据库model配置及ts识别功能

多数据库配置本来egg-sequelize-ts就已经支持的,但是egg-ts-helper默认不会按目录名和自己设置的别名生成对应的x.d.ts文件,所以在使用上不能做到很好的提示。

这里就从egg-ts-helper下手,好在这个库提供了自己实现generator的功能,可以根据自己的需求生成对应的x.d.ts文件。

我要实现的功能:

// config文件:
config.sequelize = {
    datasources: [
        getSqlConfig({
            delegate: 'model.testModel',
            baseDir: 'model/test',
            database: 'egg_test'
        }),
        getSqlConfig({
            delegate: 'model.test2Model',
            baseDir: 'model/test2',
            database: 'egg_test2'
        })
    ]
};

// service文件
async showData() {
    const {
        model: {
            testModel,
            test2Model,
        }
    } = this.ctx;
    const [[ userTest ]] = await testModel.query('select * from `user`');
    const admin = await test2Model.Admin.findByPk(1);
    const userTest2 = await test2Model.User.findByPk(1);
    return { userTest, admin, userTest2 };
}

可以看到上面配置了两个model,分别使用数据库egg_test和egg_test2,并且挂在到context.model上。对了,这里还实现把query方法绑定到model上,之前这样调用是会报错的,因为ts识别不到。具体实现,在根目录添加tshelper.js文件,具体如何使用可参考官方文档:github.com/whxaxes/egg…

// 把query挂在model上
const sequelizeModelContent = `
interface Model {
    query(sql: string, options?: any): function;
}
`

function selfGenerator(config) {
    if (!config.modelMap) {
        throw 'modelMap must not be undefined'
    }

    const modelMap = {};
    Object.entries(config.modelMap).forEach(item=> {
        modelMap[item[0]] = {
            name: item[1],
            pathList: []
        }
    })
    const modelList = config.fileList.map(item=> ({name: item.split('/')[0], path: item}));
    for (let i = 0;i < modelList.length;++i) {
        const map = modelMap[modelList[i].name];
        if (!map) {
            throw 'modelName must not be null';
        }
        map.pathList.push(modelList[i].path);
    }
    const importContent = [];
    const modelInterface = [];
    const modelContent = [];
    Object.entries(modelMap).forEach(([k, v])=> {
        const item = {name: v.name, contentList: []};
        modelContent.push(item);
        v.pathList.forEach(path=> {
            const name = path[0].toUpperCase() + path.replace('/', '').slice(1, -3);
            importContent.push(`import Export${name} from '../../../${config.directory}/${path.slice(0, -3)}'`);
            item.contentList.push(`        ${path.split('/')[1].slice(0, -3)}: ReturnType<typeof Export${name}>;`);
        })
        modelInterface.push(`            ${v.name}: T_${v.name} & Model;`)
    })
    const content = `
${importContent.join('\n')}
${sequelizeModelContent}
declare module 'egg' {
    interface Context {
        model: {
${modelInterface.join('\n')}
        }
    }
${modelContent.map(item=> {
        return `    interface T_${item.name} {
${item.contentList.join('\n')}
    }`
    }).join('\n')}
}
`;

    return {
        dist: config.dtsDir + '/index.d.ts',
        content
    }
}

module.exports = {
    watchDirs: {
        model: {
            directory: 'app/model',
            modelMap: {
                test: 'testModel', // 每增加一个model目录,就在这里新增一个对应的映射关系,key为目录名,value为前面config里设置的对应的delegate
                test2: 'test2Model'
            },
            generator: selfGenerator
        }
    }
}

多redis配置

egg-redis插件本来也是支持多redis配置的,不过也没有挂载到app上,导致每次使用都需要这样:

(this.redis as Singleton<Redis>).get('test').xxx()

这里就介绍一种方法,挂载到app上,添加application扩展文件

import { Context, Singleton, Application } from 'egg';
import { Redis } from 'ioredis';

/**
 * 扩展application
 */
export default {
    get testRedis(this: Application) {
        return (this.redis as Singleton<Redis>).get('test');
    },
    get test2Redis(this: Application) {
        return (this.redis as Singleton<Redis>).get('test2');
    }
};

// 这样就可以直接在service里这样使用:
const {
    app: {
        testRedis,
        test2Redis
    }
} = this.ctx;
const set1 = await testRedis.set('test', 1);
const set2 = await test2Redis.set('test', 2);

// 当然,还需要在config里配置:
config.redis = {
    clients: {
        test: getRedisConfig({ db: 13 }),
        test2: getRedisConfig({ db: 14 })
    }
};

这里有一个不好的地方,就是新增一个redis库都需要在application扩展里写上对应的库的获取,后续再看看修改egg-redis让其自动挂载到application上。

路径别名

在项目里,一般我们引用文件,经常会遇到**../../../../xxx**这种很长很长的路径,我本人不是很喜欢这样,因为如果复制到另一个文件,可能路径就变了,而稍微不注意就会报错,所以研究引入了路径别名,可以像这样使用:

require('module-alias/register');
import BaseService from '@base/baseService';

// @base指向app/base目录。

配置,首先ts支持配置路径别名识别,在tsconfig.json中配置:

"compilerOptions": {
    ....
    "baseUrl": ".", // 一定要设置,项目的路径
    "paths": { // 这里配置相应的别名
      "@base/*": ["./app/base/*"],
      "@util/*": ["./app/util/*"],
      "@lib/*": ["./app/lib/*"],
    }
  },

ts配置好后,只是ts能识别,但没用啊,最后我们跑的还是js,也要配置js能识别。这里就借用第三方库module-alias

在package.json中配置别名

"_moduleAliases": {
    "@base": "./app/base",
    "@lib": "./app/lib",
    "@util": "./app/util"
},

同时在需要使用别名的文件上面加入一句

require('module-alias/register');

至此就可以使用路径别名了,告别长长的路径。

但是这里每次都需要写多一行require('module-alias/register');,还是比较繁琐,希望ts以后的版本在编译时能自动根据配置的别名转换下路径,这样就完美了哈哈。

一行命令生成路由和service

因为对项目进行了改造,每个controller类都有很多公共的部分

require('module-alias/register');
import BaseController from '@base/baseController'; // 引入baseController
import { AController, GET } from '@lib/aRouter'; // 引入aRouter
// 自动识别路径
const __CURCONTROLLER = __filename.substr(__filename.indexOf('/app/controller')).replace('/app/controller', '').split('.')[0].split('/').filter(item => item !== 'index').join('/').toLowerCase();

// 配置路由
@AController(__CURCONTROLLER)
export default class indexController extends BaseController {

}

如果每次新建都需要这样复制太麻烦了(我太懒了哈哈),所以就想到了用命令来生成文件。

一开始的思路是自己写个js文件来生成,后来了解到了plop。所以这个项目就用这个。在项目dev-script目录下。

router是路由模板,也就是生成controller。

service是service模板。

还需要在根目录下新增plopfile.js文件

const routerGenerator = require('./dev-scripts/plop-templates/router/prompt');
const serviceGenerator = require('./dev-scripts/plop-templates/service/prompt');

module.exports = function (plop) {
    plop.setGenerator('router', routerGenerator);
    plop.setGenerator('service', serviceGenerator);
};

在package.json新增一个命令

"scripts": {
    "new": "plop"
},

这样就可以直接一行命令自动生成文件

// 在app/controller/test目录下新建一个index.ts文件
npm run new router test/index

plop的语法怎么使用这里就不多做介绍了,可自行上网查看。

最后

这个脚手架已经用在公司多个项目上,目前没有什么问题。

欢迎各位体验以及提提意见~。

原文链接:egg+typescript搭建后端项目