基于 KOA2 + Typescript 的 web 开发框架介绍

本文是对之前 KOA2系列 文章的一次总结、升级、优化, 也是基于 TypeScript 对之前 Web 框架的重写.

本文介绍的 KOA Web 框架特点:

  • 基于 JavaScript 超集 TypeScript 重写
  • Web Session 存储于 redis 缓存
  • 引入 ORM 框架 node-orm2 管理 models 对象, 本文采用 mysql 数据库
  • 引入数据库迁移管理工具 migrate-orm2
  • 参考 Django 路径设计思路, 重新设计文件目录
  • 使用 PM2 管理守护 Web 进程

问题

该工程可能在 windows 环境下不太友好

环境要求

1、Node 环境 node >= 7.6 (需要原生的 async/await 语法支持)
2、推荐采用 yarn 管理依赖, 使用npm 也可以
3、TypeScript >= 2.x
4、PM2 安装配置

  • 全局安装 PM2: npm install -g pm2
  • 安装 PM2 的 TypeScript 解释器: pm2 install typescript (执行该命令建议翻墙)

5、获得 mysql 数据库配置参数
6、获得 redis 缓存配置参数

使用该框架

框架 git 仓库地址: https://github.com/FeifeiyuM/koa2-typescript
1、拉取框架代码 git clone git@github.com:FeifeiyuM/koa2-typescript.git
2、安装依赖: yarn install ( npm install )
3、添加 mysql & redis 配置参数, (如何修改在后面介绍)
4、启动服务:

  • 开发环境启动服务: 在工程根目录下执行命令: npm start
  • 生产环境部署服务: 在工程跟目录下执行命令: npm run deploy

5、数据库更新迁移:(需要编写更新文件)

  • 执行数据库迁移命令: npm run migrate

6、删除开启的所有 PM2 进程

  • 执行删除进程命令: npm run del

工程目录结构

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
koats
├──config # 工程需要的相关配置文件,存放在该路径下
| ├──env.config.ts # 用于存放系统需要的相关配置参数, 例如:数据库,缓存,端口
| ├──pm2.dev.config.json # 开发环境下,PM2 启动的配置文件
| └──pm2.prod.config.json # 生产环境下, PM2 启动的配置文件
|
├──migrations # msyql 更新迁移相关脚本目录
| ├──index.ts # migrate 执行的脚本入口文件
| ├──001-test.js # 这样的文件会有很多,是数据库更新的相关配置脚本
| └──...
|
├──src #开发文件路径
| ├──utils # 公共模块的存放路径,例如下面本工程中添加的:
| | ├──auth.ts # 用户验证模块
| | ├──orm.ts # 建立 orm 模块与数据库连接的公共模块
| | ├──redis.ts # 连接 redis 的公共模块
| | ├──session.ts # session 存取的公共模块
| | └──test.ts # 测试脚本
| |
| ├──base #与业务无关的相关逻辑代码
| | ├──api.ts # api 接口文件
| | ├──models.ts # 该 models.ts 用于初始化其他业务相关的 models
| | ├──router.ts # 用于设置与业务无关的相关路由, 例如 首页等
| | └──test.ts # 测试脚本
| |
| ├──account # 业务模块,用路径名区分业务,每个路径下都包含如下四个文件, 例如 account 表示用户管理相关业务
| | ├──api.ts # api 接口文件
| | ├──models.ts # orm 数据模型文件, 将数据库字段转换成相应的数据对象
| | ├──router.ts # 与该业务相关的路由配置
| | └──test.ts # 测试脚本
| |
| ├──pets # 另一个业务
| |
| ├──app.ts # 整个 Web 工程的入口文件
| └──views #view层文件放置路径, 模板、js、css/less等文件
| ├──layout # 公共模板文件

├──views #展示层代码,即 html 文件路径
| ├──layout # nujunks 模板文件的公共模板路径
| | ├──base-h5.html # 移动端的公共模板
| | └──...
| ├──base # 与业务无关相关页面
| | ├──home.html #首页
| | └──...
| ├──account # 业务相关页面,推荐此类路径命名与 src 路径下的业务命名一致
| | └──...
| └──... # 如果需要加入打包相关工具的话,推荐加在该路径下
|
├──public # 静态文件路径
├──logs # 日志文件输出路径
├──node_modules # node依赖路径,node引入依赖自动生成
├──package.json # 工程管理文件,系统依赖管理,启动命令、环境变量配置,
├──tsconfig.json # TypeScript 配置文件
├──.gitignore
└──README.md

config 配置信息

1、env.config.js 文件

该文件中主要用于编辑系统相关的配置信息,例如数据库连接,开放端口号等信息
该文件主要配置信息主要分为两部分,一部分是开发环境的配置参数, 另一部分是生成环境的配置数据

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
//判断当前环境类型
const isProdEnv:boolean = process.env.NODE_ENV === 'production'

//开发环境下的配置参数
//listen port
let listenPort:Number = 3000
//Redis Config
let redis = {
port: 6379,
host: '192.168.1.100',
db: 8
}
//Mysql Config
let mysql = {
host: '192.168.1.100',
database: 'nodeweb',
user: 'root',
password: 'mysql123',
protocol: 'mysql',
port: '3306'
}

//生产环境下的配置参数
if(isProdEnv) { //覆盖开发环境的参数
listenPort = 3000
redis = {
port: 6379,
host: '192.168.1.100',
db: 9
}
mysql = {
host: '192.168.1.100',
database: 'nodeweb',
user: 'root',
password: 'mysql123',
protocol: 'mysql',
port: '3306'
}
}
//模块导出
export default {
listenPort: listenPort,
redis: redis,
mysql: mysql
}

2、PM2 启动配置文件

PM2 启动文件分为开发、生产两个, 两个文件大致相同
name: PM2指进程名; script: 入口执行文件,即 app.ts;
log_file: 指日志文件,PM2 会自动搜集系统输出日志,并输出至 logs 目录下的 app.log 文件
error_file: error及以上级别日志, 输出至 logs 目录下的 err.log 文件
PM2 配置编写说明

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
{
"apps" : [{
"name": "devenv",
"script": "./src/app.ts",
"instances": 1,
"exec_mode": "fork",
"watch": true,
"ignore_watch" : ["node_modules", "public", "logs", "views", "package.json", "config", ".git/*"],
"out_file": "./logs/app.log",
"error_file": "./logs/err.log",
"log_date_format" : "YYYY-MM-DD HH:mm Z",
"combine_logs": true,
"listen_timeout": 8000,
"kill_timeout": 1600,
"env": {
"NODE_ENV": "development"
}
}]
}
{
"apps" : [{
"name": "prodenv",
"script": "./src/app.ts",
"instances": 1,
"exec_mode": "fork",
"watch": false,
"out_file": "./logs/app.log",
"error_file": "./logs/err.log",
"log_date_format" : "YYYY-MM-DD HH:mm Z",
"combine_logs": true,
"listen_timeout": 8000,
"kill_timeout": 1600,
"env": {
"NODE_ENV": "production"
}
}]
}

TypeScript 配置

TypeScript 配置文件 tsconfig.json tsconfig 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"compilerOptions": {
"module": "commonjs",
"target": "es2016",
"noImplicitAny": false,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"outDir": "build",
"watch": false
},
"files": [
"src/app.ts"
]
}

系统入口文件 app.ts

src 目录下的 app.ts 文件与之前KOA2系列文章中介绍的大致相当, 不作详细介绍
注意: 模块引入时, 有些模块不能直接采用 import Koa from ‘koa’, 需要改成: import * as Koa from ‘koa’

utils 模块介绍

1、session.ts: 参考 koa2-web-Session

2、redis.ts: 参考 koa2-web-数据持久化

3、orm.ts: 参考 koa2-web-数据持久化node-orm2 具体 model 配置将在后面介绍

4、auth.ts: 用户认证模块,这个是 koa-router 的中间件函数, 在路由中执行

1
2
3
4
5
6
7
8
9
10
11
12
13
enum UserType {  //用户类型定义
super = 0,
normal = 1,
operator = 2
}
export const userAuth = async (ctx, next) => {
if(ctx.session.isLogin) { //判断 Session 中的状态判断登入状态
await next()
} else {
ctx.status = 403 //认证失败
ctx.body = {ERROR: 'user is not autherized'} //直接返回错误码
}
}

base 路径下文件介绍

1、该目录下需要介绍是 models.js 文件,这个文件主要是用来初始化其他业务的 model

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
//init models
import * as Log4js from 'koa-log4'
const logger = Log4js.getLogger('models')
import DbConn from '../utils/orm' //引入 utils 中的 orm 连接模块
import { User } from '../account/models' //引入 account 中的 user model 初始化函数
import { Pets } from '../pets/models' //引入 pets 中 pets model 初始化函数
//定义一个全局的 orm 对象, 防止调用该 InitModels 模块时反复连接数据库
let connection:any = null
//分别初始化每个model,
const setUp = (db):void => {
User(db)
Pets(db)
}
const InitModels = ():Promise<any> => {
if(connection && connection !== 'initing') {
logger.info('models has been inited') //如果已经初始化过,直接返回实例化的 orm 对象
return Promise.resolve(connection)
} else {
connection = 'initing'
return new Promise((resolve, reject) => { //如果未初始化, 连接数据库
DbConn(null).then(db => {
setUp(db)
logger.info('models inited successfuly')
connection = db //赋值全局变量
resolve(db) //返回 orm 实例化对象
}).catch(err => {
logger.info('models inited failed')
reject(err) //放回错误
})
})
}
}
export default InitModels

2、api.ts, router.ts, test.ts 用法与其他业务模块的类似,在后面介绍

业务模块 (Account为例)

1、数据建模( models.ts ), 将数据库中的某一张表,对应映射成一个数据对象。

这一块主要涉及 node-orm2 的使用, 详细文档可以参考 node-orm2 wiki
主要设计的知识点有 node-orm2 wiki 中的 Defining Models, Defining Associations
以用户表为例:

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
import * as orm from 'orm'
export const User = (db):void => { //以函数形式导出, 给上面介绍的 InitModels() 调用
const user = db.define('user', { // 'user' 为表名, 对应数据库中的 user 表
// id 字段, 对应 user 表中的 id 字段,
//{ }, 中描述的是该字段的属性, 具体查看 node-orm2 wiki 中的 Model Properties
id: {type: 'serial', key: true},
password: {type: 'text', size: 128, required: true}, // user 表中的 password 字段
type: {type: 'integer', size: 0|1|2, defaultValue: 0},
nick: {type: 'text', required: true},
mobile: {type: 'text', required: true},
last_login: {type: 'date', time: true},
create_time: {type: 'date', time: true, required: true},
update_time: {type: 'date', time: true, required: true}
}, {
validations: { //提交字段校验 具体查看 node-orm2 wiki 中的 Model Validations
nick: [
orm.enforce.unique('nick has been used'), //唯一性校验
orm.enforce.ranges.length(5, undefined, 'nick must be at least 5 letters long'),
orm.enforce.ranges.length(undefined, 30, 'nick can not be longer than 30 letters') //长度校验
],
mobile: [
orm.enforce.unique('this mobile has be registed'), //唯一性校验
orm.enforce.ranges.length(11, 11, 'mobile number length must be 11')
]
},
hooks: { //事件钩子, 定义了数据存取的各个阶段, 具体查看 node-orm2 wiki 中的 Model Hooks
beforeValidation(next) {
this.create_time = new Date().toISOString().slice(0, 19)
return next()
}
},
methods: { //序列化输出, 为 user 对象定义一个 baseInfo 方法,获取对应字段
baseInfo() {
return {
nick: this.nick,
mobile: this.mobile,
type: this.type
}
}
}
})
}

2、定义接口(api.ts), 将主要的业务逻辑放在 api.ts 中实现, 包括 数据存取,更新, 相关业务逻辑

以 Account 下 api.ts 为例:主要实现用户创建和用户信息读取的功能
需要注意的点:

  • 在 api.ts 中导入的是 utils 中的 InitModels 方法
  • api 类中必须定义一个init()方法, 用于初始化整个 model,
  • api 中所有的方法返回的都为 Promise 对象
  • api 中主要用到的是数据库 增,删,改,查功能, 涉及的知识点有 node-orm2 wiki 中的 Finding Items, Creating and Updating Items, Aggregation
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
import * as Log4js from 'koa-log4'
//这边要特别注意,在此导入的是 base 目录下的 model 初始化函数
// 而不是在当前路径下,定义的 models.ts
import InitModels from '../base/models'
const logger = Log4js.getLogger('account')
class Account {
userM: any = null //用于存储实例话后的 user Model
models: any = null //存储整个 model 对象
constructor() {}
//初始化 model, 这个每个 api 类中必须定义的一个方法
//用于初始化 model 对象
init():Promise<any> {
return new Promise((resolve, reject) => {
InitModels().then(db => {
this.userM = db.models.user //提取 user model
this.models = db.models //实例化后的整个 model
resolve(true)
}).catch(err => {
reject(false)
})
})
}
getById(id:number):Promise<any> { //根据用户id, 拉取用户信息
return new Promise((resolve, reject) => {
this.userM.get(id, (err, result) => {
if(err) {
reject(err)
}
//调用 user model 中 method 中定义的 baseInfo 方法
//输出有效信息
resolve(result.baseInfo)
})
})
}
createUser(userInfo):Promise<any> { //实现业务逻辑 创建用户
return new Promise((resolve, reject) => {
const createTime = new Date().toISOString().slice(0, 19)
userInfo.update_time = createTime
logger.debug('to create user', userInfo)
//采用 user model 对象中的 create 方法,创建一条数据库数据
this.userM.create(userInfo, (err, result) => {
if(err) {
reject(err)
}
resolve(result)
})
})
}
}
export default Account

3、路由编写 (router.ts), 实现页面渲染 或 http 接口

路由编写同之前的 KOA2系列 中的 REST 接口编写类似
本文中的添加的内容是 koa-router 中间件, 以实现用户登入校验
注意: 每个 router.ts 都要添加 model 初始化的代码, 主要为了保证在接口调用前 model 都已初始化

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
import * as Router from 'koa-router'
import * as Log4js from 'koa-log4'
import Account from './api' //导入之前编写的 api 类
import { userAuth } from '../utils/auth' //导入用户认证函数
import { errAnal } from '../utils/error' //异常处理函数
const router = new Router()
let account:any = null // 全局存储 account 对象
const logger = Log4js.getLogger('account')
//该函数是初始化 model 中间件
const initAccount = async (ctx, next) => {
if(!account) { //如果 account 对象为定义, 保证该对象只会初始化一次
account = new Account()
try {
await account.init() //等待 account 初始化成功, 本质上是等待整个 model 初始化成功
} catch(err) {
logger.error('account init failed')
}
await next()
} else { //如果已经初始化的话,直接下一步
await next()
}
}
//当有请求进入时,先检查 account 是否已经初始化
//这部分代码同 api 中 init()方法配合使用, 时必须添加的
router.use(initAccount)
router.prefix('/account') //给当前文件中的 所有路由都加个 account 前缀
//如下 /account/info/:id 这个接口访问需要登入
//在进入到逻辑处理函数前,先添加 userAuth 函数验证用户
//如果验证通过 则进入到之后的逻辑处理函数,
//如果处理失败, userAuth 函数中会直接返回错误
router.get('/info/:id', userAuth, async(ctx) => {
let id = Number(ctx.params.id)
let userInfo:any = null
try {
userInfo = await account.getById(id)
} catch(err) {
logger.error(err)
}
ctx.body = JSON.stringify(userInfo)
})
// 如下: /account/register 这个接口不需要用户验证, 则不用添加 userAuth 方法
router.post('/register', async (ctx) => {
let nick:string = ctx.request.body.nick
let mobile:string = ctx.request.body.mobile
let password:string = ctx.request.body.password
let type:number = Number(ctx.request.body.type)
let user = {
nick: nick,
mobile: mobile,
password: password,
type: type
}
let result:any = null
try {
result = await account.createUser(user)
ctx.body = JSON.stringify(result)
} catch(err) {
logger.error('in error', err)
errAnal(ctx, err)
}
})
export default router

4、 test.ts 用于编写测试脚本

数据库迁移 migration

本框架中采用的数据库迁移工具是 migrate-orm2
执行命令: npm run migrate 会执行 migrations/index.ts 脚本, 根据脚本中的配置会相应调用那些数据库更新脚本,(例如: 001-test.js)

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
import * as MigrateTask from 'migrate-orm2' 
import * as Log4js from 'koa-log4'
import DbConn from '../src/utils/orm'
const logger = Log4js.getLogger('migrate')
logger.info('to start migrate')
DbConn(null).then(conn => {
logger.info('orm connect successfully')
const task = new MigrateTask(conn.driver)
//第一步生成新的迁移脚本 001.test.js
//在 001-test.js 中会包含up, down 两个方法
//在up, down 方法中可以编写数据库迁移操作: 如: createTable, addColumn 等
// task.generate('test', (err, resp) => {
// if(err) {
// logger.error(err)
// } else {
// logger.debug('result' ,resp)
// }
// }
task.up('001-test.js', (err, resp) => { //执行 001-test.js 中的 up 方法
logger.info('----task up finished----')
if(err) {
logger.error(err)
} else {
logger.debug('result', resp)
}
})
task.down('001-test.js', (err, resp) => { //执行 001-test.js 中的 down 方法
logger.info('----task down finished----')
if(err) {
logger.debug(err)
} else {
logger.info('result', resp)
}
})
}).catch(err => {
logger.error('orm connect failed')
})

注意:

  • migrate-orm2 工具目前(2017/03/07)无法实现修改字段名称
  • dropColumn(或 drop相关操作)请谨慎使用, 有时会将整个表给删除

package.json 模块

package.json 中主要介绍系统的启动命令, 在 script 中编写
npm start: 按开发环境配置启动整个工程, 并输出日志
npm run deploy: 按生产环境配置启动整个工程,
npm run migrate: 执行数据库迁移脚本
npm run del: 删除所有 PM2 正在运行的进程

1
2
3
4
5
6
"scripts": {
"start": "pm2 start config/pm2.dev.config.json && pm2 logs",
"deploy": "pm2 start config/pm2.prod.config.json",
"migrate": "pm2 start migrations/index.ts && pm2 logs",
"del": "pm2 delete all"
}

END