概述

应用入口

如果没有使用Rockerjs-MVC,你就不会见到这么简洁的入口文件。

index.ts

@Application
class App extends AbstractApplication{
  public async beforeServerStart(server, args: RockerConfig.Application) {
    Logger.info('beforeServerStart hook ' + args.name + args.uploadDir);
  }
  public static async main(args: RockerConfig.Application) {
    Logger.info('main bussiness '  + args.name + args.uploadDir);
  }
}

入口是一个类,它被注解 “@Application” 修饰,同时继承抽象类 “AbstractApplication”,实现静态方法main函数和实例方法beforeServerStart函数(可选)。

入口文件其实并没有处理核心逻辑,如中间件的初始化、错误处理、过滤器及路由以及服务器的创建,仅仅提供了一个hook-beforeServerStart函数处理服务器创建前的逻辑和静态方法main函数。在main函数中,可处理与业务逻辑相关的操作,但并不建议在main函数中执行中间件初始化、错误处理等操作,因为这些操作可以有更优雅的实现。

事实上,中间件、过滤器、路由以及服务器的创建是在Rockerjs-MVC内部容器中完成的:

  1. 通过扫描工程的所有目录及文件,预加载所有的JavaScript文件,初始化注解;
  2. 解析不同环境的配置文件,初始化组件和starter;
  3. 触发 beforeServerStart hook;
  4. 初始化filters并编排职责链,递归校验所有控制器,设置路由;
  5. 初始化traceContext,提供内置链路追踪功能;
  6. 执行Main函数,启动应用

Rockerjs-MVC容器仅仅根据一份配置文件,完成了复杂应用的初始化。那么,下一节将介绍应用的设置。

设置应用

TypeScript设置

Rockerjs-MVC采用ES6和ES7部分相关特性构建,同时使用了TypeScript的decorators,因此需要有一份模板的tsconfig.json文件用来参考:

{
  "compilerOptions": {
    "target": "es6",
    "allowJs": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "commonjs"
  },
  "exclude": [
    "node_modules",
  ]
}

需要注意的是,如果要使用Rockerjs-MVC的trace功能,只能把 target 设置为es6。如果设置为es7,使用await/async会造成无法追踪上下文的问题。

由于使用了TypeScript的注解特性,因此需要设置“experimentalDecorators”和“emitDecoratorMetadata” 为 true。

应用配置

Rockerjs-MVC基于配置文件运行。Rockerjs-MVC维护的容器扫描并解析所有的配置文件,根据配置的环境以及不同组件的参数和顺序,在启动流程的不同阶段初始化组件与Starter、过滤器、MVC框架和Main函数,最终运行程序。因此,应用配置文件是Rockerjs-MVC应用的重中之重。

在Rockerjs-MVC应用中,配置文件可放在任意目录下,但必须遵循文件的命名规范:

app.${env}.config

其中,env为枚举类型 { dev, daily, pre, prod}。

Rockerjs-MVC会根据环境变量 NODE_ENV 判断当前运行环境。因此,当执行命令 NODE_ENV=dev node index 则意味着运行在 dev 环境,容器会解析 “app.dev.config”文件开始启动逻辑。

app.${env}.config文件遵循 ini配置规范 。ini规范非常简洁,包括 “section、key、value”三种概念。 section用 “[]” 包裹,在应用中它的名称 必须是目标类的首字母小写;key则标识配置项名称;value意味配置项值,默认是字符串,同时支持boolean和数字。

app.${env}.config 实例

; this comment is being ignored
port=8080

; files or dirs need to excludes scan
excludesDir[]=pages/perf/3rd
excludesDir[]=pages/perf/dist
excludesDir[]=pages/perf/src

[application]
name=npids
uploadDir=../uploads

[filter:trace]

[filter:bodyParser]
uploadDir=../uploads

[renderPlugin]
wrapper=/pages/perf/base.ejs

[midLogger]
starter=@rockerjs/midlog-starter
env=daily

[mysql]
starter=@rockerjs/mysql-starter
host=127.0.0.1
user=NODE_PERF_APP_user
port=3308
database=NODE_PERF_APP
password=root
resourcePath=model/resource

说明:

  • 配置文件采用 “;” 进行行注释;
  • 配置项port和excludesDir 没有在某个 section 下,因此属于全局配置项。这两项是Rockerjs-MVC提供的额外配置:port配置服务器的监听端口;excludesDir是个数组,它告知Rockerjs-MVC在启动应用时忽略扫描JavaScript文件所在的目录
  • [application] 是一个section。它是一个特殊的section,定义了应用入口 @Application 装饰的入口类的配置。在类的实例化对象的 beforeServerStart 方法和静态 main 方法中可以直接获取改配置
@Application
class App extends AbstractApplication{
  public async beforeServerStart(server, args: RockerConfig.Application) {
    Logger.info('beforeServerStart hook ' + args.name + args.uploadDir);
  }
  public static async main(args: RockerConfig.Application) {
    Logger.info('main bussiness '  + args.name + args.uploadDir);
  }
}
  • [filter:trace]、[filter:bodyParser]分别表示两个Filters -- Trace和BodyParser 的配置。需要注意的是,Filter配置的 section 必须带有 “filter:” 前缀,同时section的顺序代表 filter 处理请求的顺序(本例中,trace是第一个过滤器,bodyParser是第二个过滤器)。另外,配置中的filter名称必须是具体实现类的首字母小写:filter:trace的实现类类名为Trace,filter:bodyParser的实现类类名为BodyParser
import * as BP from "koa-body";

// 实现类类名为BodyParser,实例方法init接受的参数args来自于配置项 [filter:bodyParser]
@Filter
export class BodyParser extends AbstractFilter {
  args: RockerConfig.BodyParser;
  init(args: RockerConfig.BodyParser) {
    Logger.info('bodyParser filter init with args: '  +    JSON.stringify(args)); 
    this.args  =  args;
  }

  async doFilter(context, next) {
    let  uploadDir  =  path.join(process.cwd(), this.args.uploadDir);
    await  BP({
      multipart: true,
      urlencoded: false,
      text: false,
      json: false,
      formidable: {
        uploadDir: uploadDir,
        keepExtensions: true,      
      }
    })(context, next);
  }
  
  destroy() {
    Logger.info('bodyParser filter destroy');
  }
}
  • filter:trace 即使没有具体的配置项,也要配置具体的section,这是因为filter的section顺序标识了过滤器的处理顺序,如果配置文件中不存在对应filter的section,Rockerjs-MVC则不会将这个过滤器加入到职责链中处理请求
  • section [renderPlugin] 配置渲染插件, 相应的渲染实现类的类名为 RenderPlugin
@Plugin
export  class  RenderPlugin  extends  AbstractPlugin {
  do(config: RockerConfig.RenderPlugin) {
    return  render({
      wrapper: config.wrapper,
      env: config["env"] ||  config[CONFIG_FILE_ENV] ||  "prod"
    });
  }
}
  • sections: [midLogger] 和 [mysql] 标识两个组件的配置项。这两个组件都有相同的key:“starter”。在Rockerjs-MVC中,“starter” 是一种特殊的组件,它会自动初始化组件逻辑并处理内部依赖,开发者只需引入对应的starter并提供配置即可。在配置文件中key:starter传入了 “@rockerjs/midlog-starter”和“@rockerjs/mysql-starter” ,这表示这两个starter(两个NPM包)会处理日志和mysql的初始化逻辑,只需要传入env(midLogger)和host(mysql)等参数即可。具体更详细的说明,可参考 “starter”一节

控制器

控制器负责处理传入的HTTP请求。控制器是一个带有 @Controller(path) 注解的类,其中path标识当前控制器所匹配的路径。

HomeController.ts

import { Controller, Get, Param, Request } from '@rockerjs/mvc';

@Controller("/home")
export class HomeController {
  @Inject
  mainService: MainService;

  @Get({url: '/', render: './base.ejs'})
  async home(@Request req) {
    return {};
  }
  
  @Get({url: '/a'})
  async home(@Param("name") name: string, @Param("person") person: object) {
    let  a  =  await this.mainService.sendMsgThenquery();
    return {
      tag: 'hello world',
      a,
      name,
      person
    };
  }

  @Get({url: '/queryAppInfo'})
  async queryAppInfo(@Request req) {
    let ret = await this.mainService.requestNpsServer(req.ctx);
    return ret;
  }

  @Post({url: '/uploadProfile'})
  async  uploadProfile(@Request req) {
    return await this.mainService.uploadProfileFromAgent(req.ctx);
  }
}

在编码控制器过程中,遵循一个ts文件对应一个controller规范,且导出类必须是具名类,不能默认导出(export default class {})

通过注解 “@Inject” 注入依赖,此例中注入MainService的实例至HomeController实例属性中。在MainService中实现具体业务逻辑,Controller专注于请求分发处理。

通过注解 “@Get()、@Post()、@Head()” 修饰实例方法,表明匹配的请求类型。可通过传入 url 指定匹配路径,也可通过 render 指定渲染模板路径,当控制器返回Model对象时,由Rockerjs-MVC调用对应的渲染插件渲染模板并返回(内置ejs模板解析器,可通过@Plugin提供自定义模板的解析)。

通过注解 “@Param()、@Request、@Response” 获取请求的参数、请求对象和响应对象。针对Get请求,@Param() 注解获取的是对应名称的查询字符串;而对于Post请求,获取的是解析后的body体中的字段。@Request 和 @Response 注解可获取对应的请求和响应对象,即Koa的Request和Response对象。

服务

MVC模型中,由C(Controller)调用服务(Service),服务执行具体的逻辑。Rockerjs-MVC中没有为服务提供特殊的注解,只要是个普通类即可。

import { Inject } from "@rockerjs/mvc";
export class MainService {

  @Inject
  db: AppInfoDao

  @Inject
  npsApi: HttpNpsRequest

  async sendMsgThenquery() {
    let result = await this.db.queryByName('yangli');
    return {
      result,
    }
  }
  
  async requestNpsServer() {
    let a = await this.npsApi.queryYourAppsInfo();
    return {
      a
    };
  }
}

在MainService类中,通过注入 AppInfoDao 和 HttpNpsRequest 类实例来使用mysql和RPC 中间件服务,而这些中间件的初始化及使用,请看下一节:组件

组件

组件常用来封装 “中间件、存储或者定时任务”,它是Rockerjs-MVC容器启动过程中最先初始化的。组件之间不存在顺序逻辑,所有组件间不存在依赖,一同执行初始化操作。

组件有一套实现规范: “使用@Component修饰,并继承抽象类AbstractComponent”

export abstract class AbstractComponent {
  public static Module: any;
  public abstract start(config: any): Promise<any> | any;
}

静态属性 Module 用来表示组件的具体模块或者实现类,通过引用Module属性可直接使用相关方法;

实例方法start初始化组件,参数为 app.${env}.config 配置文件中对应的项。

一个Redis组件

import * as RedisClient from "@rockerjs/redis";

@Component
export class Redis extends AbstractComponent {
  public static Module: typeof RedisClient;
  public name: string;

  constructor() {
    super();
    this.name = "redis";
  }
  
  public async start(config: RockerConfig.Redis) {
    Logger.info(`Redis Component is starting ...`);
    // init
    Redis.Module = RedisClient;
    RedisClient.RedisClient.create(config.env, config.name);
    Logger.info(`Redis Component start succussfully!`);
  }
}
  • 把Redis包装为组件,采用 集成分布式链路追踪特性的NPM模块:@rockerjs/redis创建Redis客户端
  • 必须实现静态属性 Module 和实例方法start
  • Redis.Module赋值为 具体的Node模块或类,其他模块依赖 @rockerjs/redis的类时,可直接引用 Redis.Module(这种方式主要在引用starter时出现 )
  • 实例方法 start 的参数类型为 RockerConfig.Redis,RockerConfig是Rockerjs-MVC用CLI工具 “rockerjs-cli” 生成的声明文件的命名空间,它声明了配置文件 “app.${env}.config”的所有配置项及其类型,这样即可在编码过程中直接使用对应模块的配置项。RockerConfig.Redis的名字为配置文件中 section名称的首字母大写。 redis的配置项:
[redis]
env=daily
name=nps

组件本质上是功能实体的聚合,且大多数情况有自己的生命周期,如初始化。是否抽象为组件需要开发者仔细斟酌,需要注意的是,组件由容器最先初始化并启动

Starter

Rockerjs-MVC引入了starter机制,starter机制是根据编码习惯来约定惯用的相关模块配置,从而简化开发者的环境初始化和相关编码工作,让开发者将目光聚焦在模块的使用上。

starter是一种特殊的组件,它由第三方提供,使用者仅需在配置文件中配置相关项即可使用它。目前,Rockerjs生态提供了几种常用的starter:

  1. @rockerjs/midlog-starter: 携带分布式链路追踪trace信息的日志starter
  2. @rockerjs/mysql-starter: 接入分布式链路追踪的基于XML模板的ORM starter
  3. @rockerjs/rpc-starter: 接入分布式链路追踪的Dubbo/HTTP客户端 starter
  4. @rockerjs/redis-starter: 接入分布式链路追踪的redis客户端 starter
  5. @rockerjs/rocketmq-starter: 接入分布式链路追踪的rocketmq starter

Rockerjs将常用的中间件产品封装为starter,开发者无需在应用入口文件根据环境初始化所有产品,只需在配置文件 app.${env}.config 配置对应starter的项即可。

使用方法

使用starter非常简单,只需在配置文件 app.${env}.config设置对应starter的section,同时设置 starter配置项为具体的NPM包名即可。以使用mysql-starter为例, 配置文件:

[mysql]
starter=@rockerjs/mysql-starter
host=127.0.0.1
user=NODE_PERF_APP_user
port=3308
database=NODE_PERF_APP
password=root
resourcePath=model/resource

配置文件中,section 名称为mysql,这是由starter @rockerjs/mysql-starter指定的,开发者可根据对应starter文档进行配置。剩余的配置项(如host、user)等都是starter初始化时需要的参数。

@rockerjs/mysql-starter导出Mysql类,所以在配置文件中section仍然采用类的首字母小写规则

通过简单几行的配置,我们就完成了mysql的环境初始化,就可以在应用程序中使用了。

import { Mysql } from "@rockerjs/mysql-starter";
const { Column, DOBase, Mapping, Transaction } = Mysql.Module;
import { AppInfo } from "../do/App_Info";

export class AppInfoDao extends DOBase {
  public async add({appid,secrete,username,appname}) {
    const fId = await this.exe<any[]>('appInfo:add',{
      appid,secrete,username,appname
    });
    return fId;
  }

  @Mapping(AppInfo)
  public async queryAll() {
    const ary = await this.exe('appInfo:queryAll', {});
    return ary;
  }
}
  • @rockerjs/mysql-starter导出Mysql类,在代码中通过import方式引入
  • 通过解构 Mysql.Module 使用具体的方法和工具类
  • 按照starter文档编写对应代码,@rockerjs/mysql-starter底层引用了@rockerjs/包,因此本质上mysql-starter是封装了@rockerjs/dao的组件,编码规范大体上遵从@rockerjs/dao,采用注解和XML模板的ORM框架

编写一个Starter

starter本质上就是一个组件,只是容器在启动时加载starter的策略与组件略有不同而已。 因此,编写starter实质上就是编写组件。

import * as db from "@rockerjs/dao";
import { Logger } from "@rockerjs/common";
import { Component, AbstractComponent } from "@rockerjs/mvc";  

@Component
export class Mysql extends AbstractComponent {
  public static Module: typeof db;
  public name: string;

  constructor() {
    super();
    this.name  =  "mysql";
  }

  public async start(config) {
    Logger.info(`mysql-starter is starting ...`);
    db.start([{
	  database: config.database,
	  host: config.host,
	  password: config.password,
	  port: config.port,
	  sqlPath: config.resourcePath,
	  user: config.user,
	}]);
	Mysql.Module = db;
	Logger.info(`mysql-starter start succussfully!`);
	return db;
  }
}

mysql-starter就是一个组件,它采用 @Component装饰同时继承AbstractComponent。它与普通组件的不同点在于,mysql-starter引入了ORM框架@rockerjs/dao并封装了其初始化操作(尽管这个初始化很简单)。

starter编写必须遵循组件编码规范,尽可能将依赖、环境配置和初始化等逻辑封装足够好,让使用者无需关心这些非核心逻辑。

另外,starter需要有足够详细的使用文档,如配置规则、使用方式等。

过滤器

过滤器是在服务器处理具体请求前的前置处理器,可在过滤器拦截、处理请求。过滤器的设计采用Koa的洋葱模型,关于洋葱模型的请求处理顺序,可参考Koa文档。

过滤器是一个类,采用注解 @Filter并继承 AbstractFilter类,实现其生命周期方法。

export abstract class AbstractFilter {
  public abstract init(args: any): void;
  public abstract async doFilter(context: Application.Context, next): Promise<void>;
  public abstract destroy(): void;
}

实例方法init和destroy为生命周期方法:init初始化过滤器;destroy则销毁,回收资源。

实例方法 doFilter 接受两个参数,分别为 context和next(同Koa2),代表请求上下文和职责链指针,过滤器的逻辑在doFilter内实现。

过滤器可理解为Koa中间件的对象形式,其实例方法doFilter可类比为Koa中间件

编写一个过滤器非常简单,

import tracer from '@rockerjs/tracer';
import { Filter, AbstractFilter } from '@rockerjs/mvc';
import { Logger } from "@rockerjs/common";

@Filter
export class Trace extends AbstractFilter {
  init(args: RockerConfig.Trace) {
    Logger.info('trace filter init with args: '  + JSON.stringify(args));
  }

  async doFilter(context, next) {
    await tracer()(context, next);
  }  

  destroy() {
    Logger.info('trace filter destroy');
  }
}
  • 过滤器的配置规范为 “filter:${类名首字母小写}”
  • 过滤器的处理顺序为配置文件中的配置顺序
  • init方法的参数是RockerConfig.Trace类型,它对应自配置文件的section如下
[filter:trace]
name=trace
  • doFilter方法逻辑同Koa2的中间件相同