Koa MC

使用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的写法开始也不太习惯。还是要自己深入的学习源码。

你可能感兴趣的:(Koa MC)