封装一个Nodejs框架实践

前言

在大多数nodejs项目里都使用了ExpressJS框架进行开发,ExpressJS 是一个简洁而灵活的 Node.js Web应用框架, 提供一系列强大特性帮助你创建各种 Web 应用,express对nodejs自带的HTTP模块和路由做了适度的封装,并加入了中间件功能,足以应付大多数的项目开发,笔者也是用ExpressJS做基础框架,在做过几个项目之后,结合项目组成员及项目的一些特点,我想对传统的项目结构做了一下调整,我希望按不同的业务模块进行目录划分,每个模块目录可拥有独立的controller、service、model、static等,主要目的也是让开发人员更关注具体的业务,一些杂活就要框架去处理好了。

本文采用目前比较主流的框架及模块进行实践,底层框架使用Express,ORM使用了SequelizeJS,日志模块log4js,模板引擎nunjucks。文章中不会介绍上述模块的使用以及框架里具体代码实现,只是介绍如果将这些框架模块结合起来开发一套自己的框架。

为什么要封装

单从实现业务需求上来说,不封装也是完全可以的,封装的主要目的有以下几点:

  1. 希望即使从未接触过Nodejs和其它框架模块的人也可以快速开发,实现基本业务需求,熟悉javascript的同学,即使没有后台经验,也能完成前后端开发任务。
  2. 针对团队项目特点,做更多的抽象及复用,建立内部开发规范,为团队开发带来便利提高开发效率。
  3. 方便扩展和升级,如果底层的某个功能模块需要升级,不会影响到已编写的业务代码,哪怕是更换了底层模块,只要保证调用的方法一致,也是可以正常运行的。
  4. 通过封装实践,能更多的了解底层框架及用途,纯粹学习和提高自己的设计能力:)

如何设计

在封装之前,先考虑一下最终如何给人使用,即开发人员的项目结构应该是什么样,需要做哪些事情等,这决定了框架开发规范及约束的设计,如果要实现各模块独立开发,每个模块拥有自己的控制层、服务层之类的,应该需要一个moduels目录用于存放各个目录,通常做一个WEB项目最常见的功能有路由定义、视图渲染、数据操作及业务逻辑处理几大项,在这我采用了传统的MVC分层方式,为了根目录更加简洁一点,我考虑用app做为根目录,将模块等都放在app目录下,另外还需要有一个配置目录,用于区分不同环境下的配置,最后设计的目录如下:

目录结构

├─app
│  |- modules    //模块目录
│  │  |─ module_A   //业务模块A
│  │  │  |─ ctrls //控制器目录
│  │  │  |   └─ controller1.js
│  │  │  |─ views //视图模板目录
│  │  │  |   └─ index.html
│  │  │  |─ [static] 静态文件等
│  │  │  |─ [models] 数据处理文件
│  │  │  |─ [servs]  服务层 业务处理
│  │  │  └─ [router.js] 模块的路由配置文件
│  │  |── module_B 模块B
│  |─ [models]  //ORM 模型定义(顶级,表示可通用)
│  |─ [routes]  //通用路由器配置
│  |─ [views]   //通用视图模板
|  |- [bridge]  //桥接文件目录
│  └─ [ctrls]   //通用控制器文件
├─ config -> 环境配置目录
│   |─ default.js 默认配置文件
│   |─ [development.js]  //开发环境配置文件
│   |─ [production.js]   //生产环境配置文
│   └─ [testing.js]      //测试环境配置
└─ run.js         //启动文件

这样看来,整个项目的根目录就只有app、config两个,再加一个run.js用于启动项目,modules目录用于存放各个模块,每个模块可以编写自己的业务代码,当然,不能强制开发人员什么情况都需要使用模块,所以即使没有modules模块也是可以的,我们可以直接将一些通用或是不需要划分模块的代码写到顶级,即app目录下。
现在从项目结构上看,我们已经定出了一个开发规范,框架根据上面的目录结构进行路由及模块的动态加载,上面加方括号的表示可选项。

有了原型结构,就可以开始封装了,我们先从路由开始,先来看一下Express中路由的定义:

//来自官方文档
var express = require('express');
var app = express();

app.get('/', function(req, res) {
  res.send('hello world');
});

上面定义了一个首页路由,输入域名会返回hello world,这是个非常简单的示例,首先我们需要引入express,然后定义一个HTTP请求方法GET、POST等,当然一般情况下我们会将路由写到独立的文件里,然后再启动项目时导入,但是不管写多少个路由文件,都是需要引入require('express'),如果将路由的句柄(就叫控制器吧)也单独写到文件中,则需要在路由文件中引入控制器文件,来看一下例子:

home.js (回调函数句柄-控制器)

module.exports = {
    home: function(req, res){
        res.send('home');
    },
    
    about: function(req, res){
        res.send('about');
    }
}

main_router.js (路由文件)

const express = require('express');
 //需使用 express.Router 创建模块化、可挂载的路由
const router = express.Router();
//引入控制器
const homeController = require('controller/home')

// 匹配根路径的请求
app.get('/', homeController.home);
// 匹配 /about 路径的请求
app.get('/about', homeController.about );

app.js

//在应用中加载路由模块:
var express = require('express');
var app = express();
var router = require('./main_router');

app.use('/', router);
...

路由文件的作用就是如何定义应用的端点(URIs)以及如何响应客户端的请求,我们想一下,其实关键的几个点就是定义了请求方法和指定回调函数,所以能不能将路由做一个配置文件就可以了,对于引入框架和控制器这些都交由框架实现就好了,比如把上面的路由文件写成这样:

//router.js
exports.routers = [
    { prefix: ["/", "/index.html"], ctrl: "home", action: "home" },
    { prefix: "/about", ctrl: "home", action: "about"}
]
  • prefix 指定路由的路径
  • ctrl 指定路由命中后要执行的控制器
  • action 指定默认执行的方法,跟 ctrl 控制器中指定的方法名对应
  • [method] 指定请求的方法, 比如get, post, put等,默认值为get

当框架启动时,路由模块router.js应该根据ctrl参数自动加载控制器文件,再将action函数注入到Express路由里,这样配置过后,不需要在应用中再进引入路由文件,开发人员只需要编写控制器文件,实现业务逻辑就好。

模块路由
既然分模块开发,一般情况路由的地址也会按模块进行区分,所以如果是模块下的路由,框架考虑自动将模块目录名做为前缀添加到URI前面,比如模块名为user,其下的路由都需要加上 /user/.. 进行访问。

当然,我们还要考虑让用户自定义前缀名称,所以再加一个参数可以额外指定前缀:

//router.js
//指定模块别名 第一参数为别名,第二个为模块名
exports.url_prefix = ["a", "user"]
...

加上别名后,模块下的路由都会通过别名进行访问,比如要访问上面routers,需要添加别名:

  • http://.../a/index.html
  • http://.../a/about

过滤器
当然,像这样简单的配置还是不够的,比如要实现一个过滤器,这是一个很常用的功能,那就考虑给配置项添加一个新的节点filter,如下:

//router.js
exports.routers = [
    { prefix: "/about", ctrl: "home", action: "about", filter: "login_required"}
]
//定义过滤器
exports.filters = {
    //定义一个过滤器名称
    "login_required": {
        //prefix指定是一个挂载,如果是*号就是模块下所有路由都应用
        prefix: "mount",
        //handler 要执行的句柄
        handler:  function(req, res, next){
            if(req.session.loginUser == undefined ){
                res.redirect('/user/login.html')
            }else next();
        }
    }
}

当访问/about时,框架要先执行过滤器 login_required 方法,当然,过滤器的定义可以写成一个单独的文件。
除了过滤器,框架还实现了多控制器处理等功能,实现的代码在这里不详解了,可以参考源码或者示例源码

数据层ORM封装

数据层主要是使用Sequelize做为基础框架,要使用ORM需要先定义好数据模型,先来看一下官方的例子:

//引入sequelize
const Sequelize = require('sequelize');
//定义一个模型
const User = sequelize.define('user', {
  firstName: {
    type: Sequelize.STRING
  },
  lastName: {
    type: Sequelize.STRING
  }
});
//导出方法
exports.addUser = function(userName, email) {
    //向 user 表中插入数据
    return User.create(...);
}
//通过用户名查找用户
exports.findByName = function(userName) {
    return User.findOne(...);
}

我希望让开发人员以类的形式去声明数据模型,所以我打算按下面的样子封装一下,让框架能按类的形式去定义模型:

class Users {
    constructor(){
        super()

        this.tableName = "user"
        this.fields = {
            firstName: {type: "string(11)"},
            lastName: {type: "string(11)"}
        }
    }
    
    //添加一个获取用户列表的方法
    addUser (userName, email) {
        return this.create(...);
    }
    //通过用户名查找用户
    findByName (){
        return this.findOne(...);
    }
}

这种方法,隐去了直接掉用 Sequelize 的方法,这样的目的是为了让非nodejs开发人员去太关注Sequelize的用法,只需要关注如果实现业务就好,另外一点是如果要更换数据库框架或是升级,可以不用去修改这些模型,不影响业务。
既然按类的形式去封装了,那是不是可以给每个模型都定义一个基类,这样可以给模型附加一些基础的方法,比如这样写:

class Users extends APP.DB.DBModel{
    ...
}

APP.DB.DBModel 是框架提供的一个基类,提供一些基础或是扩展的方法,比如一些对数据格式化转换之类的。当然,如果按类的方法封装后,要想使用一些原生的方法怎么办?我们可以考虑将类做一个代理,通过反射将原生方法暴露出去:

//通过代理调用Sequelize原生方法
var cProxy = new Proxy(class, {
    get: function(target, key, receiver) {
        if (target[key] == undefined) {
            //target.ORM 是sequelize对象
            return target.ORM[key];
        }
        return target[key];
    }
});

定义好模型后,在需要用到数据模型的代码里引入模型就可以使用,示例代码:

//导入模型
const models = require("./models")
var m_user = models.Users;
let u = await m_user.getone({where: {"firstName": "xx"}})
console.log(u.firstName)

桥接文件

桥接文件主要是给开发人员编写额外通用业务代码所用的,也是用框架自己加载执行,比如我们需要给模板引擎添加方法变量、添加一些通用中间件等。看一个给模板引擎的例子:

//templateExt.js 专门用于定制模板文件
module.exports.tpl  = {
    //init初始化方法,将会自动执行
    init (app){
        //获取模板对象
        let tpl = app.get('tpl');
        //添加一个日期过滤器
        tpl.addFilter('formatTimestamp', function(t, f="yyyy-MM-dd HH:mm:ss"){
            return new Date(t*1000).pattern(f)
        })
        ...
    }
}

//comm.js 通用文件
module.exports.comm = {
    methodA (){ ... }
}

在代码任何地方,我们可以通过框架提供的全局变量APP访问到桥接文件里的成员,比如要访问comm.js里的methodA方法,可以通过APP.comm.methodA形式访问。

配置文件

配置文件主要用于让开发人员指定不同环境下所需要的配置参数,比如指定数据库、日志等相关信息,通常我们会使用一个JSON文件定义配置,在JSON里配置不同环境下的节点参数,比如:

//config.js
{
    'development': ...
    'testing': ...
    'production': ...
}

这里,我考虑使用类的形式来定义,感觉看起来比较清晰独立一点,最后部署的时候,只需要对应的配置文件就可以了:

//config.js
class Config{
    constructor(){
        //是否开启调试模式
        this.debug = false;
        //数据库连接配置
        this.database = {...};
        //自定义变量
        this.BaseUrl = "/"
        //配置日志信息
        this.log4js = { ... }
    }
}
module.exports = Config;

//development.js
var Config = require('./config');
class Development extends Config{
    constructor(){
        super();
        this.debug = true;
    }
}
module.exports = Development;

启动文件run.js

启动文件就比较简单了,很多都是框架做了,所以启动文件只需要引入框架,启动服务就可以:

//run.js 启动文件
var em = require('express-moduledev');
var config = {
    //指定端口,默认端口 8000
    "port":801,
    //使用环境 'default','development','production','testing'
    "use_env": "development",
}
//启动服务
em.Run(config)

至此,给用户呈现一个怎么样的框架已经都明确了,只要框架去实现这些功能就好了。

框架结构

根据上面的这些需求定义,我们知道框架要具备哪些的核心功能,大概可以画出一个脑图,如下图:


framework.png
  • 主文件index.js:
    这个文件包括web服务的启动,配置文件的载入及核心模块的初始化等,跟平常我们开发Express应用时的主入口相似
  • 路由模块 router.js:
    用于加载和处理路由,自动加载与相关的控制器文件,实现过滤器和中间件方面的处理
  • 基础文件base.js:
    这个文件提供控制器的加载管理,并导出一个全局对象APP,方便在项目中使用框架提供的各项功能接口
    加载桥接文件
  • 数据库 db.js
    处理数据库相关的事情,对用户定义的模型进行解析,对底层ORM进行封装,并提供了最基础的方法
  • 日志模块 log.js
    为开发人员提供一个日志API,处理日志相关的事情
  • 通用单元 utils.js
    这个文件主要是给框架内部使用,包含一些通用判断函数等

框架源码

附上框架源码,欢迎浏览改进:)
https://github.com/rob668/express-moduledev

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

推荐阅读更多精彩内容