使用Koa做Web HTTP服务,根据MVC来组织项目结构,由于Koa自身已经在Express上精简了很多,在MVC结构上不免会向Koa的app上灌入很多对象,使用require去加载文件当项目规模越来越复杂时会变得异常复杂。现在Node中找到比较好的IoC,只找到Bearcat,已经在Pomelo中使用了Bearcat。Pomelo自身又不支持async/await,如是种种,蛋疼。现在先做HTTP服务器,借鉴Pomelo中一些比较不错的地方。
简单梳理一下,使用Node搭建的MVC框架,由于Web服务最终会做前后端分离,所以这里的MVC其实只是M和C,主要用来做HTTP接口使用。V已经被独立出来,所以这里也就先不管它了。Web框架使用的是Koa,为了避免不断地require。M和C以及router何如优雅的组合在一起,是一个头疼的问题。现在虽然已经完成,但不知道性能如何。关键部分,在两个位置,第一点是在router中动态加载controller,第二个位置是在controller中动态加载model。其实这两点最终归结到一点就是动态加载的问题。因此抽离出一个单独的加载器。参考多方面的资料,又因为在Pomelo中发现RPC的写法比较优雅,说白了,就是比较省事儿,省代码。
好了,简单说下原本的需求,本来是在是用Pomelo做实时应用,为了剥离出一些HTTP接口的服务,因为开始做一个HTTP MVC的服务。
废话说了一堆,首先看下拼积木的组件。
依赖
$ vim web-server/package.json
{
"name": "web-server",
"version": "1.0.0",
"description": "web server",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"web",
"server"
],
"author": "junchow",
"license": "ISC",
"dependencies": {
"koa": "^2.11.0",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"mysql2": "^2.1.0",
"sequelize": "^5.21.3"
}
}
这里只是用最基础的koa、koa-route、sequelize、mysql。后续需要的再添加。
结构
HTTP服务的MVC结构
文件夹 | 描述 |
---|---|
app.js | 项目入口文件 |
config | 配置文件夹,配置文件以JSON格式,分为development和production。 |
config/app.json | 默认应用的配置文件 |
route | 路由文件夹,根据应用划分路由文件。 |
route/web.js | web应用的路由规则文件 |
util | 工具文件夹 |
util/loader.js | 文件加载器 |
app | 应用文件夹,内含MVC组织结构。 |
app/controller | 应用控制器文件将 |
app/model | 应用数据模型文件夹 |
app/service | 服务层文件夹 |
app/middleware | 应用中间件文件 |
app/view | 应用视图文件夹 |
入口
应用入口文件
$ vim app.js
const path = require("path");
const Koa = require("koa");
const koaStatic = require("koa-static");
const KoaRouter = require("koa-router");
const Sequelize = require("sequelize");
//环境类型
const env = process.env.NODE_ENV || "development";
//创建应用
const app = new Koa();
//创建路由
const router = new KoaRouter();
//定义路径
const basePath = __dirname;
const configPath = path.join(basePath, "config");
const staticPath = path.join(basePath, "static");
const routePath = path.join(basePath, "route");
const utilPath = path.join(basePath, "util");
const appPath = path.join(basePath, "app");
const controllerPath = path.join(appPath, "controller");
const modelPath = path.join(appPath, "model");
const viewPath = path.join(appPath, "view");
//读取配置
const config = require(path.join(configPath, "app"))[env];
//配置静态资源目录
app.use(koaStatic(staticPath));
//链接数据库
const sequelize = new Sequelize(config.sequelize);
//测试链接
sequelize.authenticate()
.then(_=>console.log("connection has been established successfully"))
.catch(err=>console.error("unable to connect to the database", err));
//动态加载
const Loader = require("./util/loader");
app.controller = Loader.load(controllerPath);
app.context.model = Loader.load(modelPath, sequelize);
//配置路由
require(path.join(routePath, "web"))(app, router);
//监听端口
app.listen(config.server.port, config.server.host, _=>{
console.log("server start", config.server.host, config.server.port);
});
说明一下
- 加载配置,根据当前环境env参数加载默认配置文件config/app.json中的配置项。
- 路径规划,根据项目文件夹组织结构定义路径变量,主要靠path完成,也可以把这些路径挂到app或ctx下。
- 数据库连接,项目启动就会直接连接数据库,这个没什么好说的。
- 动态加载,这里的重点是做了一个加载器Loader,动态加载文件,这个地方很重要。
重点说明一下,app.js应用入口文件中最重要的是app这个对象,以及app对象上的context。其实不想将很多东西挂到app和context上,但为了代码书写更加方便,性能方面都没考虑。
配置
应用配置
$ config/app.json
{
"development":{
"server":{
"host":"127.0.0.1",
"port":3000
},
"sequelize":{
"host":"127.0.0.1",
"port":3306,
"username":"root",
"password":"root",
"database":"pomelo",
"dialect":"mysql",
"dialectOptions":{
"charset":"utf8mb4"
},
"pool":{
"min":0,
"max":5,
"idle":10000,
"acquire":3000
},
"timezone":"+08:00"
}
}
}
关于动态加载应用,在app.js入口文件中首先定义了当前环境类型,默认使用development。
//环境类型
const env = process.env.NODE_ENV || "development";
然后是根据定义的环境类型进行require
const config = require(path.join(configPath, "app"))[env];
以后添加组件时,直接在app.json中加配置项,在app.js中使用config。
数据库
数据库这里有些蛋疼,使用的是sequelize这个ORM。为什么要使用ORM,虽然ORM会消耗性能,为了省事儿,其次是为了兼容不同的数据库。自己用mysql和mogoose做也可以,这里主要是为了学习学习Node的ORM。
在app.js中只是使用sequelize作为数据库配置和连接最后生成sequelize对象,这个对象在创建model的时候是必须的。不可能在每个model中都先config然后再connect,这又会产生一堆冗余代码。如果将初始化连接单独成一个文件,那每个model中有是一堆的require。真心不喜欢require,项目只会越来越复杂,一旦require,日后重构会跟见鬼似的。问题来了,如何将sequelize传递给每个model呢?这里先思考思考,留在后面再说。
//链接数据库
const sequelize = new Sequelize(config.sequelize);
//测试链接
sequelize.authenticate()
.then(_=>console.log("connection has been established successfully"))
.catch(err=>console.error("unable to connect to the database", err));
路由
先来说下路由,路由是MVC的第一关。
//配置路由
require(path.join(routePath, "web"))(app, router);
这里默认加载了route/web.js,web.js表示使用web应用的所有路由规则,传入了app和router。其中这种写法也不是很好,如果不传入app和router,web.js只是返回router,也会存在几个问题。先看下web.js的路由:
$ vim route/web.js
module.exports = (app, router) => {
//路由规则
router.get("/", app.controller.index.index);
router.get("/test", app.controller.index.test);
//路由配置
app.use(router.routes()).use(router.allowedMethods());
};
看到app.controller.index.index
这种写法,我个人比较喜欢,第一个路由文件中无需引入每个controller,要是引入那就是一堆的日后头疼的东西。其次这种写法比较直观,直接对应着文件夹的结构,最终到达具体的方法。不过问题是app也就到此为止。从router到controller以后,如果不require的话,app也就不能再使用了。那究竟是如何完成这种写法的,这里还是要加载器起的作用。因为之前已经通过Loader加载器将controller挂到app上去了。
//动态加载
const Loader = require(path.join(utilPath, "loader"));
app.controller = Loader.load(controllerPath);
这里还是loader起了关键的作用,也是寻思参照几个方案,再加上pomelo中使用方式比较多,所以最终觉得这些方式比较不错。至于后面再说。
控制器
既然路由已经达到了控制器,控制器接受路由时都会有两个参数ctx上下文和next回调函数。由于koa已经支持async/await,这一点儿是非常非常好的,以前的回调地狱的代码逻辑,看起来很痛苦。虽然也有async的waterfall,说实话看着也很别扭。在pomelo中,由于rpc不支持async/await,我也不得不使用async,两种方式对比,真的感觉很不一样。
$ vim app/controller/index.js
exports.index = async (ctx, next)=>{
const where = {aid:900005};
const users = await ctx.model.user.findAll({where});
console.log(users);
ctx.body = "homepage";
};
exports.test = async (ctx, next)=>{
console.log("index test");
};
路由到了具体的控制器方法后,不用说,控制器要去调用模型获取数据。这里也是一个寻思好久的位置。因为如果又是一堆require,会难受香菇。所以重点是这里 const users = await ctx.model.user.findAll({where});
这里是直接调用app/model文件夹下的user模型,user模型是使用sequelizer创建的,sequelizer的model中提供了findAll的方法。最开始的想法是如何不适用require去加载模型,后来发现搞不了。接着又想如何在ctx上做些什么,才开始试了做了几个demo。发现代码真心丑。现在终于可以更加直观了。虽然这里是ctx,因为app到了controller,已经无法直接在使用了。所以这里就只能在ctx上搞事情了。回到app.js入口文件中,使用自定义的Loader加载器动态加载了model并穿入了sequelize对象,这一点儿很重要。
app.context.model = Loader.load(modelPath, sequelize);
写法上开始估计有点儿怪异,不会熟悉后估计会好很多,当没有了require,感觉真的好很多。
模型
模型这里也说上太多,因为使用了sequelizer,所有的工作都是它完成的,自己也没有去做什么事情。之前没用orm自己用mysql做个model的基类,来拼sql,用起来也还可以,自己封装的,用起来方便。又来了orm最起码更换数据库后,这里将会强大不少。sequelizer也是才找到,慢慢开始学习使用。先学习学习Node的ORM再说。
const Sequelize = require("sequelize");
module.exports = sequelize=>{
const model = sequelize.define("users",{
aid:{type:Sequelize.BIGINT(11), allowNull:false},
unick:{type:Sequelize.STRING(20), allowNull:false},
uface:{type:Sequelize.STRING(255), allowNull:false},
});
model.removeAttribute("id");
model.removeAttribute("createdAt");
model.removeAttribute("updatedAt");
return model;
};
这里就是将接收到的sequelizer来定义指定数据库表名,简单的做了关系对象的映射。代码并不优雅,暂时对sequelizer使用不太多,先这样。先把流程打通。
加载器
其中关键的重点是在加载器上,之前使用require时并没有发现单独封装一个加载器有何作用。虽然其他的框架都会做这件事,在写了一些类似cocos的东西后发现加载器很重要。这里也是只是简单的使用了一下。有些项目会直接去fs读文件或文件夹,全部require。在网络应用中io的读写是比较吃力的一个操作,加载器会做这些事情,这里的加载器也是去读取文件,只是会动态的读取,而不会整个文件全部读取。也就是用到哪个读哪一个,另外这里的加载器会根据类似app.controller.index.index
的写法去读取模块中的方法,这一点儿是非常友好的。
$ vim util/loader.js
const path = require('path');
const fs = require('fs');
class Loader {
constructor () {}
static load(filepath, options=null){
const data = [filepath];
const handler = {
construct(target, args){
return false;
},
get(target, key, receiver){
if(key && Object.prototype.toString.call(key)==="[object String]"){
if(data.indexOf(key) === -1){
data.push(key);
}
}
const file = path.resolve(__dirname, path.relative(__dirname, data.join("/")));
//console.log(data, key, data.indexOf(key), file);
if(Loader.isDirectory(file)){
return new Proxy({path: data}, handler);
}else if(Loader.isFile(file)){
if(options){
return require(file)(options);
}else{
return require(file);
}
}
}
};
return new Proxy({path:data}, handler);
}
static isDirectory (target) {
if(!fs.existsSync(target)){
return false;
}
let stat;
try{
stat = fs.statSync(target);
}catch(e){
console.error(e);
}
if(stat && stat.isDirectory()) {
return true
}
return false;
}
static isFile (target) {
target = target + ".js";
if(!fs.existsSync(target)){
return false;
}
let stat;
try{
stat = fs.statSync(target);
}catch(e){
console.error(e);
}
if(stat && stat.isFile()) {
return true;
}
return false;
}
}
module.exports = Loader;
Loader中重点是使用Node的Proxy组件,一直对proxy这个单词感觉很怪异。虽然读了proxy的文档,还是感觉半懂不懂的状态。慢慢来吧,反正是对proxy这个单词,总是有种云里雾里的感觉,说明白又不明白,说不明白又明白。还是不是很通透。换一章再单独深入下研究研究。
好的,到此为此,基本的架子有了,起码满足了个人的需要。至于性能和问题,在使用中在优化。边做边学再思考。其他的模块添加容易,这里也没有多说。优化升级再慢慢补充。
其实,一直在想有个IoC的容器去管理这个对象,但是一直没有发现比较好的。没有找到好的轮子,可以学习借鉴下。pomelo中虽然在使用bearcat,但说实话对bearcat的写法开始也不太习惯。还是要自己深入的学习源码。