创建项目太痛苦怎么办 - 给各位大佬递上express + ts的后台node模板(集成了redis和mysql)

前言

本篇仅仅是本人自己搭建的一个模板项目而已,并不是什么行业争论的问题,只是提供一个开箱即用的基础项目配置

本人介绍

class BEON {
    name: string;
    sex: string;
    ability: number;

    constructor() {
        this.name = 'BEON';
        this.sex = '♂';
        this.ability = -1;
    }
}

项目目标

完成一个express+ts+redis+mysql的后台模板

不多bb直接上项目整体逻辑

image

当然这个结构只是大概情况而已,看着了解了解就可以了

接下来我对几个地方做点讲解

router配置

说实话对于整个router的配置我不知道我的做法是算好还是不好,但是也算给大家一个思路而已。

第一个路由文件

/**
 * @author WYX
 * @date 2020/5/9
 * @Description: 首页一级路由类
*/
import * as express from 'express';
import TemplateHTML from '../template/defaultIframe';
import usersMapper from '../mapper/usersMapper';
import {RouterDec, MyType} from '../decorators/routerDec';
import UsersServices from '../services/usersServices/usersServices';

const routerDec: RouterDec = new RouterDec();

@routerDec.BaseRequest('')
export class Index {
    private static UsersServices: UsersServices = new UsersServices()

    /**
     * 首页渲染
     * @param {express.Request} req 请求
     * @param {express.Response} res 返回
     * @returns {Promise<void>} async异步
     */
    @routerDec.RequestMapping('/', MyType.get)
    async welCome(
        req: express.Request,
        res: express.Response
    ): Promise<void> {
        const ceshi = await usersMapper.getAllUser();
        console.log(this);
        await Index.UsersServices.getUserInfo('a2', 'bbbb');
        res.render('index', ceshi);
    }

    /**
     * 返回标准html页面方法
     * @param {express.Request} req 请求
     * @param {express.Response} res 返回
     * @returns {Promise<void>} async异步
     */
    @routerDec.RequestMapping('/index.html', MyType.get)
    async backHtml(
        req: express.Request,
        res: express.Response
    ): Promise<void> {
        Index.UsersServices.changeUserInfo();
        res.writeHead(200, {
            'Content-Type': 'text/html',
            'Expires': new Date().toUTCString()
        });
        res.write(TemplateHTML.startHTML);
        res.write('测试\n');
        res.end(TemplateHTML.endHTML);
    }

}

export default routerDec;

如果第一次直看整个路由注册文件感觉可能真的比较懵逼,因为这里面并没有任何一句router.use的语句,取而代之的是我们不怎么常用的装饰器

image

这个路由文件注册了两个路由一个是根路径的('/'),另一个是('/index.html')。这哈细心的大佬可能就看到了路由注册是由 @routerDec.RequestMapping这个装饰器来进行注册的。

private static UsersServices: UsersServices = new UsersServices()这个东东后面再做解释

routerDec是怎么注册的

对于routerDec的工作方式我其实进行了两种方式的设计:

方法1

image

方法2

image

方法2利用了装饰器在类中的调用顺序,关于这个执行顺序详情可以查看官方文档ts装饰器

image

那么现在就来说一下这两种方法的优缺点吧:

方法1:

  • 编写简单,注册使用装饰器相对而言更容易查找
  • 使用方便,并且对于routerDec类的使用方便,只需要进行RequesMapping方法编写

方法2:

  • 编写简单,注册使用装饰器相对而言更容易查找
  • 使用方便,但对于routerDec类的需要进行构造对象导出
  • 可以自定义基础路由,以及多级路由

其实看到这基本上就知道两者的差距了,方法2可以进行基础路由的设定这个功能,但是方法1使用和编写都要简单点,那么对于我们程序员来说肯定要麻烦的功能丰富的啊!(其实是为了拓展性)

那么可以得到我们的代码应该是这样的:

/**
 * @author WYX
 * @date 2020/5/13
 * @Description: 路由注册装饰器类
*/
import * as express from 'express';

export enum MyType {
    'get' = 'get',
    'post' = 'post'
}

interface RouterSet {
    type: string;
    path: string;
    fn: express.RequestHandler;
}

export class RouterDec {
    router: express.Router
    baseUrl: string
    routerList: RouterSet[] = []

    /**
     * 构造函数
     */
    constructor() {
        this.router = express.Router();
    }

    /**
     * 类的基础路由
     * @param {String} path 基础路由
     * @returns {ClassDecorator} 返回构造函数
     */
    BaseRequest(path: string): ClassDecorator {
        return (): void => {
            this.baseUrl = path;
            this.routerList.forEach((item) => {
                this.router[item.type](this.baseUrl + item.path, item.fn);
            });
        };
    }

    /**
     * 注册路由
     * @param {String} path 路由路径
     * @param {MyType} type 请求方式
     * @returns {MethodDecorator} ts方法装饰器
     */
    RequestMapping(path: string, type: MyType): MethodDecorator {
        return (target: any, propertyKey: string, descriptor: PropertyDescriptor): void => {
            this.routerList.push({
                type: type,
                path: path,
                fn: descriptor.value
            });
        };
    }
}

当然了这其实也只是一个基础模板,具体使用比如需要添加put、delete等类型需要进行自主的添加。

注册文件

当然了,大佬可能要质疑我了,看了两个文件了还是没看到具体的注册地方呢?

其实这就是很简单的一个文件了,功能就是遍历了下路由文件夹里面导出的路由进行注册构建对象。

/**
 * @author WYX
 * @date 2020/5/9
 * @Description: 路由加载类
 */
import * as express from 'express';
import * as fs from 'fs';

export default class MyRouter {
    protected router: express.Router

    /**
     * 构造函数
     */
    constructor() {
        this.router = express.Router();

        this.getRouter();
    }

    /**
     * 获取router实例(写个protected感觉比较牛皮)
     * @returns {e.Router} 返回router实例
     */
    getMyRouter(): express.Router {
        return this.router;
    }

    /**
     * 注入传入路径的路由
     * @param {String} routeUrl 传入需要遍历的路径
     * @returns {void}
     */
    private getRouter(routeUrl = ''): void {
        const files = fs.readdirSync((__dirname + routeUrl) as fs.PathLike);

        files
            .filter(
                function(file: string): boolean{
                    return file.split('.')[0] !== 'loader';
                }
            )
            .forEach(
                (file: string) => {
                    if (file.split('.').length === 1) {
                        this.getRouter(routeUrl + '/' + file.split('.')[0]);
                    } else {
                        import(`.${routeUrl}/${file.split('.')[0]}`)
                            .then((route) => {
                                route = route.default.router;
                                this.router.use(`/`, route);
                            })
                            .catch((e) => {
                                console.log(e);
                                throw Error('读取路由文件失败,请检查');
                            });
                    }
                }
            );
    }

}

看了这么多都不点个赞么?你的良心不会痛么?

image

redis开启

接下来的就比较简单了,首先我们开启一个redis服务

/**
 * @author WYX
 * @date 2020/5/12
 * @Description: redis基础信息配置
*/
import * as redis from 'redis';

const $redisConfig: redis.ClientOpts = {

    host: '127.0.0.1',

    port: 6379

};

export const $redisOutTime = {

    outTime: 60 * 30

};

export default $redisConfig;
image

别、开个玩笑,我们先看看redis的配置开启我们放后面再来嘛

我们将所有的这种服务的地址作为config配置来进行管理可以达到功能和配置分开管理的目的,防止代码误操作以及更清晰明了的进行配置管理。

然后我封装了一个redis操作类,包括了登录使用在里面,并且所有获取是一个promise对象保证异步执行

/**
 * @author WYX
 * @date 2020/5/12
 * @Description: 对常用进行了封装(未封装无序和有序的内容)MyRedis类
 * 注: 对于ts类型检测,调用getFun方法的类型检测有问题(所以使用any),多重调用后类型检测的包含关系混乱
 */
import * as Redis from 'redis';
import $redisConfig from './config/configRedis';

const redisClient = Redis.createClient($redisConfig);

redisClient.auth('', function () {
    console.log('redis登录成功');
});

redisClient.on('error', function (err) {
    console.log(err);
});

class MyRedis {
    static redisClient: Redis.RedisClient = redisClient

    /**
     * set 普通的set/get 字符串型
     * @param {String} key 键值
     * @param {String} value 保存值
     * @returns {void}
     */
    static set(key: string, value: string): void {
        this.redisClient.set(key, value, this.errFun);
    }

    /**
     * get 普通的set/get 字符串型
     * @param {String} key 键值
     * @returns {Promise<String>} 返回值
     */
    static get(key): Promise<string> {
        return this.getFun((fn: Redis.Callback<string>) => {
            this.redisClient.get(key, fn);
        }) as Promise<string>;
    }

    /**
     * 设置key失效时间
     * @param {String} key 设置键
     * @param {Number} time 失效时长
     * @returns {void}
     */
    static exp(key: string, time: number): void {
        this.redisClient.expire(key, time, this.errFun);
    }

    /**
     * 删除key
     * @param {String} key 删除键
     * @returns {void}
     */
    static remove(key: string): void {
        this.redisClient.del(key, this.errFun);
    }

    /**
     * 单个key存储  hash
     * @param {String} key 设置键
     * @param {String} field hash的key
     * @param {String} value hash的值
     * @returns {void}
     */
    static hset(key: string, field: string, value: string): void {
        this.redisClient.hset(key, field, value, this.errFun);
    }

    /**
     * 单个key获取  hash
     * @param {String} key hash设置键
     * @param {String} field 获取的可以
     * @returns {void}
     */
    static hget(key: string, field: string): Promise<string> {
        return this.getFun((fn: Redis.Callback<string>) => {
            this.redisClient.hget(key, field, fn);
        }) as Promise<string>;
    }

    /**
     * 多个key存储  hash
     * @param {String} key hash设置键
     * @param {Object} argObj 需要传入的对象(暂时只封装对象传输)
     * @returns {void}
     */
    static hmset(key: string, argObj: { [key: string]: string | number }): void {
        this.redisClient.hmset(key, argObj, this.errFun);
    }

    /**
     * 多个key获取  hash
     * @param {String} key hash设置键
     * @param {Array<String>} argList 需要查询的数组
     * @returns {Promise<string[]>} 返回查询结果
     */
    static hmget(key: string, argList: Array<string>): Promise<string[]> {
        return this.getFun((fn: Redis.Callback<string[]>) => {
            this.redisClient.hmget(key, argList, fn);
        }) as Promise<string[]>;
    }

    /**
     * 全部hash获取
     * @param {String} key hash设置键
     * @returns {Promise<string[]>} 返回查询结果
     */
    static hgetall(key: string): Promise<string[]> {
        return this.getFun((fn: Redis.Callback<{ [key: string]: string }>) => {
            this.redisClient.hgetall(key, fn);
        }) as Promise<string[]>;
    }

    /**
     * 设定无返回操作错误处理
     * @param {null|Error} err 错误
     * @returns {void}
     */
    private static errFun(err: null | Error): void {
        if (err) {
            console.log(err);
        }
    }

    /**
     * 对获取函数进行Promise封装
     * @param {Function} fn 传入执行方法
     * @returns {Promise<string|string[]>} 返回Promise对象
     */
    private static getFun(fn: Function): Promise<string | string[]> {
        return new Promise<string | string[]>((resolve, reject): void => {
            fn((err: null | Error, getRslt: string | string[]): void => {
                if (err) {
                    reject();
                    throw err;
                }
                resolve(getRslt);
            });
        });
    }
}

export default MyRedis;

这个文件的注释比较多我就不做详细的解释了,大家使用的时候可以直接通过注释查看功能,在这大家需要注意以下这个地方哦

redisClient.auth('', function () {
    console.log('redis登录成功');
});

自己使用的时候需要把第一个参数改成自己redis的密码就行了。

本人封装的时候只封装了几个常用的,如果大家有需求可以自行进行封装哦,代码都是死的人是活的可以参照现有方法进行改造就行了。

redis重点

看到这里按理来说redis应该已经完了呀,怎么还重点都来了。当然接下来的才是redis使用的灵魂!

使用过java的大佬肯定很喜欢使用Cache注解吧,所以我们这个地方就简简单单的进行了redis注解的封装

image
/**
 * @author WYX
 * @date 2020/5/13
 * @Description: redis注解类
*/
import MyRedis from '../cache';
import {$redisOutTime} from '../config/configRedis';

interface CacheType {
    time: number;
}

export default class RedisDec {
    private static CacheNamespace: {string: { string: {string: CacheType} }} | {} = {}

    /**
     * 进行key设置
     * @param {String} key 传入key的namespace
     * @param {String} params 传入参数
     * @param {Number} outTime 过期时间
     * @returns {MethodDecorator} 返回方法
     */
    static Cacheable(key: string, params = 'redis', outTime = $redisOutTime.outTime): MethodDecorator {
        return (target: any, propertyKey: string, descriptor: PropertyDescriptor): void => {
            // 保存现有方法
            const setFunction = descriptor.value;

            // 获取方法需要的传参
            const getParams = RedisDec.getFunParams(setFunction);

            // 重写方法
            descriptor.value = async (...args): Promise<JSON> => {
                let reqParams = '';
                if (params !== 'redis') {
                    params.split('#').forEach((item) => {
                        const index = getParams.indexOf(item);
                        if (args[index]) {
                            reqParams += item + '-' + args[index] + '&';
                        }
                    });
                } else {
                    reqParams = 'redis';
                }
                const getValue: string = await MyRedis.get(`${key}:${propertyKey}:${reqParams}`);
                if (getValue) {
                    RedisDec.changeCacheTime(key, propertyKey, outTime, reqParams);
                    return JSON.parse(getValue);
                }
                const dueBack: JSON = await setFunction(...args);
                MyRedis.set(`${key}:${propertyKey}:${reqParams}`, JSON.stringify(dueBack));
                RedisDec.changeCacheTime(key, propertyKey, outTime, reqParams);
                return dueBack;
            };
        };
    }

    /**
     * 删除redis缓存
     * @param {String} key 需要删除namespace
     * @returns {MethodDecorator} 返回构造方法
     */
    static CacheEvict(key: string): MethodDecorator{
        return (target: any, propertyKey: string, descriptor: PropertyDescriptor): void => {
            // 保存现有方法
            const setFunction = descriptor.value;
            // 重写方法
            descriptor.value = (): any => {
                RedisDec.removeAllCache(key);
                return setFunction();
            };
        };
    }

    /**
     * 改变过期时间
     * @param {String} key 传入namespace空间
     * @param {String} propertyKey 传入方法名
     * @param {Number} outTime 传入过期时间
     * @param {String} params 传入请求参数
     * @returns {void}
     */
    private static changeCacheTime(key: string, propertyKey: string, outTime: number, params: string): void {
        const setOutTime = Math.round((new Date()).getTime() / 1000) + outTime;
        if (this.CacheNamespace[key]) {
            if (this.CacheNamespace[key][propertyKey]) {
                if (this.CacheNamespace[key][propertyKey][params]) {
                    this.CacheNamespace[key][propertyKey][params].time = setOutTime;
                } else {
                    this.CacheNamespace[key][propertyKey][params] = { time: setOutTime } as CacheType;
                }
            } else {
                this.CacheNamespace[key][propertyKey] = {[params]: {time: setOutTime}} as {string: CacheType};
            }
        } else {
            this.CacheNamespace[key] = {[propertyKey]: {[params]: {time: setOutTime}}} as{ string: {string: CacheType} };
        }
        MyRedis.exp(`${key}:${propertyKey}:${params}`, outTime);
    }

    /**
     * 删除传入namespace所有key
     * @param {String} key 需要删除key
     * @returns {void}
     */
    private static removeAllCache(key: string): void {
        if (this.CacheNamespace[key]) {
            Object.keys(this.CacheNamespace[key]).forEach((propertyKey) => {
                Object.keys(this.CacheNamespace[key][propertyKey]).forEach((params) => {
                    if (this.CacheNamespace[key][propertyKey][params].time > Math.round((new Date()).getTime() / 1000)) {
                        MyRedis.remove(`${key}:${propertyKey}:${params}`);
                    }
                });
            });
            delete this.CacheNamespace[key];
        }
    }

    /**
     * 返回
     * @param {Function} fn 传入需要进行获取参数的函数
     * @returns {Array} 返回获取参数数组
     */
    private static getFunParams(fn: Function): string[] {
        const regex1 = /\((.+?)\)/g; // () 小括号
        const getList = fn.toString().match(regex1);
        const dealString = getList[0].substring(1, getList[0].length - 1).replace(' ', '');
        return dealString.split(',');
    }
}

可能大家看着代码还不太清楚怎么使用,那么直接看看使用的地方吧,基本上和java的是一样的

/**
 * 获取用户信息服务
 * @param {String} userId 用户id
 * @param {String} ceshi 测试参数
 * @returns {string[]} 返回获取的信息
 */
@RedisDec.Cacheable('keyList', '#userId#ceshi')
getUserInfo(userId: string, ceshi?: string): string[] {
    console.log('获取用户信息');
    return ['a', 'b'];
}

是的,只需要添加一句话就可以实现redis缓存的保存和读取以及redis过期的设置

第一个参数主要是用作命名空间使用,第二个参数是处理传过来的参数也作为条件,只有当条件完全一样的时候才会进行读取redis返回,那我们来看一下在redis中是进行怎样的设置我们的key值的。

keyList:getUserInfo:userId-a2&ceshi-bbbb&

可以清晰的看到我们的保存的key是由:命名空间+方法名+各个参数的key、value对应

image

这样可以减少我们对于redis的直接代码操作(偷懒美滋滋啊)

mysql数据库

只剩下简简单单、朴实无华的mysql了,还是一样的采用配置文件的方式在这就不拿出来了,没有营养

简简单单看看mysql的封装得了

/**
 * @author WYX
 * @date 2020/5/12
 * @Description: mysql连接池配置类
*/
import * as mysql from 'mysql';
import $dbConfig from './config/configMysql';

class MySql{
    private static pool: mysql.Pool = mysql.createPool($dbConfig);

    /**
     * 封装query之sql不带带占位符func
     * @param {String} sql 执行sql
     * @param {Function} MapperReject 执行错误回调方法(推荐传入mapper的reject这样服务层可以捕获catch)
     * @returns {void}
     */
    static query(
        sql: string | mysql.Query,
        MapperReject?: Function
    ): Promise<object> {
        return new Promise((resolve, reject) => {
            this.pool.getConnection(
                function(
                    err: mysql.MysqlError,
                    connection: mysql.PoolConnection
                ): void {
                    if (err) {
                        MapperReject && MapperReject(err);
                        reject();
                    }
                    connection.query(
                        sql,
                        function(
                            err: mysql.MysqlError,
                            rows: object
                        ) {
                            if (err) {
                                MapperReject && MapperReject(err);
                                reject(err);
                            }
                            resolve(rows);
                            // 释放链接
                            connection.release();
                        }
                    );
                }
            );
        });
    }

    /**
     * 封装query之sql不带带占位符func
     * @param {String} sql 执行sql
     * @param {*} args 传入占位符
     * @param {Function} MapperReject 执行错误回调方法(推荐传入mapper的reject这样服务层可以捕获catch)
     * @returns {void}
     */
    static queryArgs(
        sql: string,
        args: any,
        MapperReject?: Function
    ): Promise<object> {
        return new Promise((resolve, reject) => {
            this.pool.getConnection(
                function(
                    err: mysql.MysqlError,
                    connection: mysql.PoolConnection
                ): void {
                    if (err) {
                        MapperReject && MapperReject(err);
                        reject();
                    }
                    connection.query(
                        sql,
                        args,
                        function(
                            err: mysql.MysqlError,
                            rows: object
                        ) {
                            if (err) {
                                MapperReject && MapperReject(err);
                                reject(err);
                            }
                            resolve(rows);
                            // 释放链接
                            connection.release();
                        }
                    );
                }
            );
        });
    }
}

export default MySql;

只是在这个地方用了以下mapper的方式,把所有接口请求给封装起来了,对于数据库操作的封装可以更好的管理我们的sql语句,以及减少我们的sql语句代码

/**
 * @author WYX
 * @date 2020/5/12
 * @Description: 用户请求sql映射类
*/
import MySql from '../db';

class UsersMapper {

    /**
     * 获取全部用户
     * @returns {void}
     */
    static getAllUser(): Promise<any> {
        return new Promise((resolve) => {
            MySql.query('select * from users')
                .then((results) => {
                    console.log(results);
                    resolve(results);
                });
        });
    }
}

export default UsersMapper;

这哈基本上就看完了整个项目的主要配置了

结束

现在这简单的放两个截图吧,一个是欢迎页面一个是报错的处理的

image

image

gif图可能加载有点慢。。。

最后放上git地址:https://gitee.com/missshen/node-express-ts-model (各位大佬欢迎使用啊提意见啊,本人是个菜鸡)

最后说一句,github是真滴慢- -

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