基于nodejs的mvc架构探索

1,403 阅读3分钟

前言

    时至2021年,nodejs在web开发领域有着举足轻重的地位,常被应用于各种业务中间件开发以及构建工具插件开发,其web框架如express、koa、fastify、egg、midway、nest等也有一定的应用空间。本文将结合常见mvc框架特性,重点阐述nodejs在web mvc框架领域中的应用价值。希望对有兴趣自己封装框架的童鞋有所帮助!

正文

    常见mvc框架核心功能如下:1.请求/响应的统一处理机制;2.静态资源映射;3.http请求分发。本文将对以上3个问题点进行阐述,分别讲述解决方案!

1 基于nodejs http模块实现

    由于原生node http模块不具备任何特性,因此上述1、2、3点都需要处理!

1.1 请求/响应的统一处理机制

    这个问题利用http模块拦截机制实现,拦截原型如下:

function createServer(requestListener?: RequestListener): Server;
function createServer(options: ServerOptions, requestListener?: RequestListener): Server;
type RequestListener = (req: IncomingMessage, res: ServerResponse) => void;

    createServer函数接受一个请求监听器,该监听器包含了请求和响应对象,可以定义拦截器对请求响应的统一处理,如下进行了简单跨域处理:

export default function interceptor(req, res) {
    console.log(`request ${req.url} is targeted!`.green.bold);
    // 跨域处理
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Allow-Headers", "*");
    res.setHeader("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
}
1.2 静态资源映射

     在对静态资源映射前,首先需要区分接口请求和资源请求,如接口请求则转发给业务代码进行处理,资源请求则直接读取本地文件返回即可。因此,需要解决以下问题:

(1)区分接口请求和资源请求

     简单区分可以采用url前缀进行区分,以/api开头的为接口请求,否则为资源请求。

export default async function(req, res) {
    try {
        if (req.url.startsWith('/api')) { //接口请求
            await dispatchActions(req, res)
        } else { //资源请求
            ...
        }
    } catch (error) {
        console.error(error);
        res.statusCode = 500;
        res.end('服务器异常');
    }
}

    资源请求又可细分为后端资源请求和前端资源请求,因此需要添加响应配置项进行处理。

let path;
const urlObj = new URL(req.url, globalThis.$HOST);
if (req.url.startsWith('upload')) { // 后端资源请求
         path = join(cwd(), urlObj.pathname);
   } else { // 前端资源请求
         let pathname = urlObj.pathname === '/' ? '/index.html' : urlObj.pathname;
         path = join(cwd(), config.views, pathname);
}
(2)对资源请求设置适当的ContentType

    Content-Type对照表可以获取常用文件后缀以及对应的contentType,通过工具类处理响应即可。

async function setContentType(path, res) {
    try {
        let contentType;
        if (path.includes('.')) {
            const suffix = '.' + path.split('.')[1];
            metaData[suffix] ? (contentType = metaData[suffix]) : (contentType = metaData['.*']);
        }
        if (contentType === 'text/html' || contentType === 'text/plain') {
            contentType += ';charset=utf-8';
        }
        if (contentType) {
            res.setHeader('Content-Type', contentType);
        }      
    } catch (error) {
        console.error(error)
    }
    
}
1.3 http请求分发
(1) 编写请求分发函数
async function dispatchActions(req, res) {
    const urlObj = new URL(req.url, globalThis.$HOST);
    const api = urlObj.pathname.split('/api')[1];
    if (getControllerExport()[api]) {
        await getControllerExport()[api](req, res);
    } else  {
        res.statusCode = 404;
        res.end('not found');
    } 
}
export function setControllerExport(data) {
    globalThis.$CONTROLLER = data;
}

export function getControllerExport() {
    return globalThis.$CONTROLLER;
}
(2) 仿照webpack require.context功能实现读取同一文件下的其他文件的默认导出
// controll/index.mjs
async function autoImpotController(dir, exclude) {
    let result = {};
    const files = await readdir(dir);
    if (files?.length) {
        for (let i = 0; i < files.length; i++) {
            if (exclude !== files[i]) {
                const module = await import('./' + files[i]);
                result = {...result, ...module.default};
            }
        }
    }
    setControllerExport(result);
}

const fileUrl = import.meta.url;
const dir = dirname(fileURLToPath(fileUrl));
const exclude = basename(fileURLToPath(fileUrl));
autoImpotController(dir, exclude);
(3) 编写controller
// controll/user.mjs
const base="/user";

const controllers = {
    getUsername : async function (req, res) {
        try {
            res.setHeader('Content-Type', 'application/json;charset=utf-8');
            res.end(getJsonResult(true, 'getUsername'));
        } catch (error) {
            throw new Error(error);
        }
    }
}

const ept = {};
Object.keys(controllers).forEach(controller => {
    ept[`${base}/${controller}`] = controllers[controller];
})
export default ept;
1.4 基于注解的controller开发

    经过查询资料发现,基于typescript的注解开发处于实验阶段,而ecmascript注解提案处于第二阶段,详见github.com/tc39/propos… ,本文暂时不作探究,感兴趣的童鞋可以自行研究下!最终的controller写法应类似:

@controller({baes: 'uesr'})
class UserController {
     @Request({path: '/user', method: 'GET', contentType: 'application/json;charset=utf-8'})
     async getUsername() {
        try {
            res.setHeader('Content-Type', 'application/json;charset=utf-8');
            res.end(getJsonResult(true, 'getUsername'));
        } catch (error) {
            throw new Error(error);
        }
     }
     
}

2 基于express模块实现

    express已经内置对静态资源的处理,详见express static函数,而且对于请求也进行了统一处理,只需要简单封装即可直接使用。

2.1 实现controller自动导入并关联express
//controller/index.mjs
const fileUrl = import.meta.url;
const dir = dirname(fileURLToPath(fileUrl));
const exclude = basename(fileURLToPath(fileUrl));
const controller  = await autoImpotController(dir, exclude);
export default controller;
...
//controller/test.mjs
export default {
    get: {
        async test(req, res) {
            res.send('ok');
        },
    },
    post: {},
    put: {},
    delete: {}
}
...
//util.mjs
const currentDir = dirname(fileURLToPath(import.meta.url));
export async function autoImpotController(dir, exclude) { //dir 为controller目录所在绝对路径, exclude为index.mjs
    let result = [];
    const files = await readdir(dir);
    if (files && files.length) {
        for (let i = 0; i < files.length; i++) {
            if (exclude !== files[i]) {
                const module = await import(join(relative(currentDir, dir) , files[i]).replace(/\\/g, '/'));
                for (let method in module.default) {
                    for (let controller in module.default[method]) {
                        result.push({
                            method, //方法名 get post put delete ...
                            url: `/${files[i].split('.')[0]}/${controller}`, //请求url 为 文件名 + 对应函数名,如/test/test
                            handler: module.default[method][controller] // 处理函数
                        }) 
                    }
                }
            }
        }
    }
    return result;
}
...
// 入口文件index.mjs
const app = new Express();
if (controller && controller.length) {
    controller.forEach(item => app[item.method](item.url, item.handler))
}
...
2.2 mongoose集成

    本文采用了mongodb作为数据库进行开发,为了进行规范化开发,本文单独抽离出schema/model的操作作为dao层,由于作简单演示,service层暂时不做。 (1)引入mongoose库到入口文件

// mongoose.mjs
import mongoose from 'mongoose';

try {
    await mongoose.connect('mongodb://su:123@localhost:27017,localhost:27018,localhost:27019/test?replicaSet=rs0');
} catch (error) {
    console.error(error);
}

export default mongoose;

(2) 数据库设计,定义schema,并导出model

//dao/user.mjs
import mongoose from '../mongoose.mjs';

const schema = new mongoose.Schema({
    name: String,
    age: {
        type: Number,
        default: 20
    },
    hobby: String,
});
// 定义shema methods or static methods
export default mongoose.model('User', schema);

(3) 直接在controller中使用,实现增删改查

// controller/test.mjs
import User from '../dao/user.mjs';

export default {
    get: {
        async getUsers(req, res) {
            try {
                const data = await User.find();
                res.status(200).json({data});
            } catch (error) {
                console.error(error);
                res.status(500).json({error: JSON.stringify(error || '服务器异常')});
            }
        },
    },
    post: {
        async addUsers(req, res) {
            try {
                const total = await User.count();
                const { num = 1, name="peter", hobby= "pingpong" } = req.body;
                const list = Array.from({length: num}).map((_, i) => ({
                    name: `${name}${total + i + 1}`,
                    hobby: `${hobby}${total + i + 1}`,
                    age: Math.round(20 + Math.random()*10)
                })) 
                const result = await User.insertMany(list);
                res.status(200).json({msg:'创建成功', data: result});
            } catch (error) {
                console.error(error);
                res.status(500).json({error: error && error.message || '服务器异常'});
            }
        }
    },
    put: {
        async updateUser(req, res) {
            try {
                const { id, name, hobby, age} = req.body;
                const query = { _id: id };
                const setter = {};
                if (name) {
                    setter.name = name;
                }
                if (hobby) {
                    setter.hobby = hobby;
                }
                if (age) {
                    setter.age = age;
                }
                if (!id || !Object.keys(setter)) {
                    throw new Error('参数缺失');
                }
                const result = await User.update(query, setter);
                res.status(200).json({msg:'修改成功', data: result});
            } catch (error) {
                console.error(error);
            }
        }
    },
    delete: {
        async delUsersByName(req, res) {
            try {
                const { name } = req.query;
                if (!name) {
                    res.status(200).json({msg:'删除成功', data: 0});
                    return;
                }
                const result = await User.deleteMany({name: new RegExp(name)});
                res.status(200).json({msg:'删除成功', data: result});
            } catch (error) {
                console.error(error);
                res.status(500).json({error: error && error.message || '服务器异常'});
            }
        }
    }
}

(4) 接口测试,推荐使用vscode REST Client插件,现奉上接口测试文件

# api.rest
# 批量新增
# POST http://localhost:3030/test/addUsers HTTP/1.1
# content-type: application/json

# {
#     "name": "liming",
#     "hobby": "football",
#     "num": 10
# }


# 批量删除
# DELETE http://localhost:3030/test/delUsersByName?name=liming HTTP/1.1


# 查询列表
# GET http://localhost:3030/test/user?name=Hen HTTP/1.1

# 更新列表
PUT http://localhost:3030/test/updateUser HTTP/1.1
content-type: application/json

{
    "id": "614301c49171f36ac7ad337c",
    "name": "钱大妈"
}

3 变更记录

2021年8月20日,增加小节2.1
2021年9月17日,增加小节2.2

4 欢迎访问原文链接

欢迎访问我的博客-基于nodejs的mvc架构探索