# 实战(三) node中间层

# 基础环境

运行node端的ts需要全局安装 ts-node,对应着自动刷新的是 ts-node-dev

安装 koa

yarn add koa
1

安装 @types/koa

yarn add @types/koa --dev
1

node端我们采用 MVC 的模式进行建立

在src下创建server文件夹,具体目录结构如图

目录

先写一个简单的入口文件 app.ts

import * as Koa from "koa";

const app = new Koa();

app.use(ctx => {
  ctx.body = "hello koa";
});

app.listen(8888, () => {
  console.log("数据监控系统🍺,server is running on port8888");
});

1
2
3
4
5
6
7
8
9
10
11
12
  • 在ts中可以直接使用import
  • 用koa启动一个最基本的服务,可以在 localhost:8888 下看到 hello koa

这里我们可以把端口号提出来,放在config中

config/index.ts

import { extend } from "lodash";
import { join } from "path";

interface configIn {
  viewDir: string;
  staticDir: string;
  env: string;
  port?: string;
}

let config: configIn = {
  viewDir: join(__dirname, "..", "views"),
  staticDir: join(__dirname, "..", "assets"),
  env: process.env.NODE_ENV
};

if (process.env.NODE_ENV == "development") {
  config = extend(config, {
    port: 8081
  });
}

if (process.env.NODE_ENV == "production") {
  config = extend(config, {
    port: 80
  });
}

export default config;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • 这里我们安装 lodash 库做为工具库,使用extend方法来合并port
  • 分别设置静态资源和页面的路径
  • 按环境变量不同分别设置开发环境和上线环境的端口

这样我们的app.ts就可以改成下面这样

app.ts

import * as Koa from "koa";
import config from "./config";

// 创建服务实例
const app = new Koa();

app.use(ctx => {
  ctx.body = "hello koa";
});

app.listen(config.port, () => {
  console.log(`数据监控系统🍺,server is running on port${config.port}`);
});
1
2
3
4
5
6
7
8
9
10
11
12
13

koa是一个非常精简的框架,不同于express,koa把所有的功能都拿了出去,通过插入不同的中间件来实现功能。

安装 koa-bodyparser@types/koa-bodyparser 来接收并解析请求

yarn add koa-bodyparser
1
yarn add @types/koa-bodyparser --dev
1

在 app.ts 中引入

import * as bodyParser from "koa-bodyparser";
app.use(bodyParser());
1
2

安装 koa-static@types/koa-static 用来绑定静态资源

yarn add koa-static
1
yarn add @types/koa-static --dev
1

这里直接使用IOC的方式来进行路由管理,就不使用 koa-route 了

安装 awilixawilix-koa 用来做容器管理

yarn add awilix awilix-koa
1

# 创建IOC容器

okk,接下来先创建容器吧

import { resolve } from 'path';
const { createContainer, Lifetime } = require("awilix");
const {loadControllers, scopePerRequest} = require('awilix-koa');
import historyApiFallback from "koa2-connect-history-api-fallback";

// 创建容器
const container = createContainer();
// 每次请求都是一个 new model
app.use(scopePerRequest(container));

// 装载所有的service(models),并将 services代码注入到controllers
container.loadModules(
  [
    resolve(__dirname, './service/*.ts'),
    resolve(__dirname, './util/SafeRequest.ts')
  ],
  {
    formatName: 'camelCase',
    resolveOptions: {
      lifetime: Lifetime.SCOPED
    }
  }
)
//注册所有路由
app.use(loadControllers(resolve(__dirname, "../controller/*.ts"), {
    cwd: __dirname
  })
);
app.use(historyApiFallback({ index: "/", whiteList: ["/api"] }));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • 通过 createContainer 创建容器
  • scopePerRequest 把容器和路由最终合并到一起,每次请求都去容器里去取
  • loadModules 把所有的 service 注入到容器中
  • loadControllers 注册所有路由
  • historyApiFallback 用于让koa2支持SPA应用程序。

# interface

先在 interface 下写几个接口文件,别管干啥的,先放着 ╮(╯▽╰)╭

interface/IApi.ts

export interface IApi {
  getInfo(url: string, arg?: Object, callback?: Function): Promise<Object>
}
1
2
3

interface/IIndex.ts

import User from '../model/user'

export interface IIdex {
  getUser(id: string): User
}
1
2
3
4
5

model/user

export default interface User {
    email: string;
    name: string;
  }
  
1
2
3
4
5

interface/ISafeRequest.ts

export interface ISafeRequest {
  fetch(url: string, arg?: Object, callback?: Function): Promise<Object>
}
1
2
3

interface/ISocketHandler.ts

export interface ISocketHandler {
  init(): void
}
1
2
3

# service

接下来写两个 service

service/IndexService.ts

import { IIdex } from '../interface/IIndex'
import User from '../model/User'

export default class IndexService implements IIdex {
  constructor() {}

  private userStorage: User[] = [
    {
      email: "yuanzhijia@yidengfe.com",
      name: "zhijia"
    },
    {
      email: "Copyright © 2016 yidengfe.com All Rights Reversed.京ICP备16022242号-1",
      name: "laowang"
    }
  ]

  public getUser(id: string): User {
    let result: User

    result = this.userStorage[id]
    
    return result
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  • 注意在写 service 时的命名要用大驼峰,后面在 controller 中要对应好,使用小驼峰
  • 在这里定义用户信息

service/ApiService.ts

// import safeRequest from '../util/SafeRequest'
import { IApi } from '../interface/IApi'

export default class ApiService implements IApi {
  
  constructor({ safeRequest }) {
    this.safeRequest = safeRequest
  }

  public getInfo(url: string, arg?: Object, callback?: Function): Promise<Object> {
    return this.safeRequest.fetch(url, arg, callback)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  • 前面在注入的时候,已经将 SafeRequest 方法注入到容器中了,在这里直接结构出来,使用 getInfo 方法调用

SafeRequest.ts

import * as fetch from 'node-fetch'
import { ISafeRequest } from '../interface/ISafeRequest'

export default class SafeRequest implements ISafeRequest {
  public async fetch(
    url: string,
    arg?: Object,
    callback?: Function
  ): Promise<Object> {
    let result = { code: "error" };
    await fetch(url)
      .then(res => res.json())
      .then(json => (result = json));
    return result
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# controller

接下来就是重头戏 controller

写两个 controller

controller/IndexController.ts

import * as Router from "koa-router";
import { route, GET } from "awilix-koa";
import User from "../model/User";

@route("/")
@route("/index")
export default class IndexController {
  private indexService
  constructor({ indexService }) {
    this.indexService = indexService;
  }
  @route("/")
  @GET()
  private async index(
    ctx: Router.IRouterContext,
    next: () => Promise<any>
  ): Promise<any> {
    const result: User = await this.indexService.getUser("0");
    console.log(result.email);
    ctx.body = await ctx.render("index", { data: result.email });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  • 引入了 koa-router 要记得安装一下,为了给 ctx 添加类型检测
  • 在构造函数中结构出 indexService,记得名字要跟 service 文件名对上,大小写注意
  • 然后要绑定路由,从 awilix-koa 引入的 routeGET 方法直接通过装饰器的方式将路由注入进去
  • 同一个类或方法上面可以用多个装饰器,@route("/") 和 @GET() 表示在 '/' 路由时通过 get 请求接口

controller/ApiController.ts

import * as Router from 'koa-router'
import { route, GET } from 'awilix-koa'
import { IApi } from '../interface/IApi'

@route('/api')
export default class ApiController {
  private apiService
  constructor({ apiService }) {
    this.apiService = apiService
  }
  @route('/test')
  @GET()
  private async test(
    ctx: Router.IRouterContext,
    next: () => Promise<any>
  ): Promise<any> {
    const result: Promise<Object> = await this.apiService.getInfo('https://api.github.com/users/github')
    ctx.body = result
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 定义几个数据接口
  • 接收前端的接口请求,然后去后端地址请求数据回来,再返回给前端

至此,一个基本的已经搭起来了。

启动服务,先打开 localhost:8081/api/test,可以看到拿到了请求的数据

api

# 渲染页面

通过 swig 模板引擎进行渲染页面。当然实际上我们是用react进行页面的绘制,这里先通过后端渲染上来,后面会做ssr同构。

安装 koa-swig koa-static co @types/co @types/koa-static

yarn add koa-swig koa-static
1
yarn add @types/koa-static @types/co --dev
1
import * as render from "koa-swig";
import * as serve from "koa-static";
import co from "co";
//配置swig(前端模板)
  app.context.render = co.wrap(
    render({
      root: config.viewDir,
      autoescape: true,
      cache: "memory",
      ext: "html",
      writeBody: false
    })
  );

  //配置静态文件目录
  app.use(serve(config.staticDir));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 在app上绑定 render 方法
  • root 指定根路径
  • 通过 serve 配置静态文件目录

# 容错处理和日志

接下来该添加日志了和做一些容错处理了。

首先安装 log4js

yarn add log4js
1

在 app 上挂载 logger

import { configure, getLogger } from "log4js";

configure({
    appenders: {
      cheese: { type: "file", filename: resolve(__dirname, "../logs/yd.log") }
    },
    categories: { default: { appenders: ["cheese"], level: "error" } }
  });
  const logger = getLogger("cheese");

  app.context.logger = logger;
1
2
3
4
5
6
7
8
9
10
11
  • 在 app 中加入 logger
  • 指定日志的输出位置

在 middleware 下创建 errorHandler.ts,分别处理 500 错误和 404 错误

middleware/errorHandler.ts

// 容错处理
import { Context } from 'koa'
import { Logger } from 'log4js'

const errorHandler = {
  error(app) {
    interface KOAContext extends Context {
      logger: Logger
    }

    // 500
    app.use(async (ctx: KOAContext, next: () => Promise<any>) => {
      try {
        await next()
      } catch (error) {
        // error logs pm2 logs
        ctx.logger.error(error)
        console.log(error)
        ctx.status = error.status || 500
        ctx.body = error || '请求出错'
      }
    })

    // 404
    app.use(async (ctx: KOAContext, next: () => Promise<any>) => {
      await next()
      if (404 !== ctx.status) return
      ctx.logger.error(ctx)
      ctx.status = 404
      ctx.body = '<script type="text/javascript" src="//qzonestyle.gtimg.cn/qzone/hybrid/app/404/search_children.js" charset="utf-8" homePageUrl="http://yoursite.com/yourPage.html" homePageName="回到我的主页"></script>'
    })
  }
}
export default errorHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  • 定义接口 KOAContext
  • 定义监听 500 的服务器错误,把错误信息写到log里去,同时把status设置成500,通知页面请求出错
  • 定义监听 404 的错误,写入log,同时把页面转到404页

# 抽离中间件

上面写了一大坨,都写在了 app.ts 中,非常臃肿,其实可以把他们都抽离出来到一个中间件中,依次加载

app.ts

import * as Koa from "koa";
import config from "./config";

// 创建服务实例
const app = new Koa();

app.listen(config.port, () => {
  console.log(`数据监控系统🍺,server is running on port${config.port}`);
});
1
2
3
4
5
6
7
8
9

middleware/loadMiddleware.ts

import * as bodyParser from "koa-bodyparser";
const render = require("koa-swig");
import * as serve from "koa-static";
import co from "co";
import { configure, getLogger } from "log4js";
import { resolve } from "path";
import historyApiFallback from "koa2-connect-history-api-fallback";
const { createContainer, Lifetime, asClass } = require("awilix"); // IOC
const { loadControllers, scopePerRequest } = require("awilix-koa"); // IOC

import config from "../config";
import errorHandler from "./errorHandler";

// 初始化IOC容器
const initIOC = app => {
  // 创建IOC的容器
  const container = createContainer();
  // 每一次请求都是一个new model
  app.use(scopePerRequest(container));

  // 装载所有的service(models), 并将services代码注入到controllers
  container.loadModules(
    [
      resolve(__dirname, "../service/*.ts"),
      resolve(__dirname, "../util/SafeRequest.ts")
    ],
    {
      // we want `TodosService` to be registered as `todosService`.
      formatName: "camelCase",
      resolverOptions: {
        lifetime: Lifetime.SCOPED
      }
    }
  );
};

// 配置log
const initLog = app => {
  configure({
    appenders: {
      cheese: { type: "file", filename: resolve(__dirname, "../logs/yd.log") }
    },
    categories: { default: { appenders: ["cheese"], level: "error" } }
  });
  const logger = getLogger("cheese");

  app.context.logger = logger;

  // 错误处理
  errorHandler.error(app);
};

// 配置渲染
const initRender = app => {
  // 配置swig(前端模板)
  app.context.render = co.wrap(
    render({
      root: config.viewDir,
      autoescape: true,
      cache: "memory",
      ext: "html",
      varControls: ["[[", "]]"], // 默认动态数据是{{}},但是为了与vue区分开来,改为[[xxx]]
      writeBody: false
    })
  );

  // 配置静态文件目录
  app.use(serve(config.staticDir));
};

// 配置路由
const initController = app => {
  // 注册所有路由
  app.use(
    loadControllers(resolve(__dirname, "../controller/*.ts"), {
      cwd: __dirname
    })
  );
  app.use(historyApiFallback({ index: "/", whiteList: ["/api"] }));
};

export default function load(app) {
  app.use(bodyParser());

  initIOC(app);

  initLog(app);

  initController(app);

  initRender(app);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
  • 定义了初始化IOC容器 initIOC
  • 定义了配置log方法
  • 定义了配置渲染方法
  • 定义了配置路由方法
  • 按顺序执行

至此一个基本的 MVC node 端已经搭完,下一章进行 SSR 同构