nodejs项目的正确打开方式,typescript + koa

koa + typescript

typescript + tslint 规范化代码,选择typeorm作为数据库的orm,选择log4js输出日志

1. 目录结构

├── bin
│   └── www.ts // 启动应用
├── logs // 存放日志
├── src 
│   ├── app
│   │   ├── components // 控制器组件
│   │   │   ├── account
│   │   │   │   ├── account.controller.ts
│   │   │   │   └── account.service.ts
│   │   │   └── user
│   │   │       ├── user.controller.ts
│   │   │       └── user.service.ts
│   │   ├── constants // 常量
│   │   │   └── index.ts
│   │   ├── core // 一些核心类或全局引用的方法
│   │   │   ├── error.ts
│   │   │   └── logger.ts
│   │   ├── database // 数据库连接
│   │   │   └── index.ts
│   │   ├── entities // 实体
│   │   │   └── user
│   │   │       ├── user.entity.ts
│   │   │       └── user.model.ts
│   │   ├── middleware // 中间件
│   │   │   ├── error.middleware.ts
│   │   │   ├── jwt.middleware.ts
│   │   │   ├── logger.middleware.ts
│   │   │   └── response.middleware.ts
│   │   └── utils // 工具函数
│   │   │   └── crypto.ts
│   │	└── app.ts // 应用启动类
│   └── environments  // 多环境配置
│       ├── env.dev.ts
│       ├── env.prop.ts
│       └── index.ts
├── nodemon.json // nodemon 配置, watch ts文件
├── package-lock.json
├── package.json
├── tsconfig.json
└── tslint.json

2. package.json

{
  "name": "huzz-koa-template",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "@koa/cors": "^3.0.0",
    "koa": "^2.11.0",
    "koa-body": "^4.1.1",
    "koa-jwt": "^3.6.0",
    "koa-route-decors": "^1.0.3",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "log4js": "^5.3.0",
    "mysql2": "^2.0.0",
    "reflect-metadata": "^0.1.13",
    "typeorm": "^0.2.20"
  },
  "devDependencies": {
    "@types/jsonwebtoken": "^8.3.5",
    "@types/koa": "^2.0.52",
    "@types/koa-router": "^7.0.42",
    "@types/koa-static": "^4.0.1",
    "@types/koa__cors": "^2.2.3",
    "cross-env": "^6.0.3",
    "nodemon": "^1.19.4",
    "ts-node": "^8.5.0",
    "tslint": "^5.20.1",
    "tslint-config-standard": "^9.0.0",
    "typescript": "^3.7.2"
  },
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon --config nodemon.json",
    "compile": "tsc",
    "start": "npm run compile && pm2 start ./bin/www --name app",
    "restart": "npm run compile && pm2 start ./dist/app/app.js",
    "stop": "pm2 stop app"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/xhuz/huzz-koa-template.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/xhuz/huzz-koa-template/issues"
  },
  "homepage": "https://github.com/xhuz/huzz-koa-template#readme"
}

3. 开始

1. core

  1. 自定义错误,
    // error.ts
    // 继承自原生Error类, 方便抛出各类异常
    export class CustomError extends Error { 
      code: number;
      constructor(code: number, message: string) {
        super(message);
        this.code = code;
      }
    }
    
  2. 配置logger
    // logger.ts 具体配置自行查看log4js 文档
    import {configure, getLogger} from 'log4js';
    import {resolve} from 'path';
    import {Context} from 'koa';
    
    const logPath = resolve(__dirname, '../../../logs'); // log存放路径,确保该路径存在
    
    configure({
      appenders: {
        console: {type: 'console'},
        dateFile: {type: 'dateFile', filename: `${logPath}/log.log`, pattern: 'yyyy-MM-dd', alwaysIncludePattern: true, keepFileExt: true}
      },
      categories: {
        default: {
          appenders: ['console', 'dateFile'],
          level: 'info'
        },
        mysql: {
          appenders: ['console', 'dateFile'],
          level: 'info'
        }
      }
    });
    
    export const logger = getLogger('default');
    export const mysqlLogger = getLogger('mysql');
    
    export function logText(ctx: Context, ms: number) {
      const remoteAddress = ctx.headers['x-forwarded-for'] || ctx.ip || ctx.ips || (ctx.socket && ctx.socket.remoteAddress);
      return `${ctx.method} ${ctx.status} ${ctx.url} - ${remoteAddress} - ${ms}ms`;
    }
    
    

2. database

配置数据库

import {createConnection, ConnectionOptions, Logger, QueryRunner} from 'typeorm';
import {environment} from '../../environments'; // 支持多环境自动导入不同配置,详见environments
import {mysqlLogger} from '../core/logger';

// 导出连接数据库函数
export function connection() {
  const config: ConnectionOptions = environment.db as any;
  Object.assign(config, {logger: new DbLogger()});
  createConnection(config).then(() => {
    console.log('mysql connect success');
  }).catch(err => {
    mysqlLogger.error(err);
  });
}

// 用我们自己的logger来接管typeorm logger
class DbLogger implements Logger {

  logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
    mysqlLogger.info(query);
  }

  logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) {
    mysqlLogger.error(query, error);
  }

  logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
    mysqlLogger.info(query, time);
  }

  logSchemaBuild(message: string, queryRunner?: QueryRunner) {
    mysqlLogger.info(message);
  }

  logMigration(message: string, queryRunner?: QueryRunner) {
    mysqlLogger.info(message);
  }

  log(level: 'log' | 'info' | 'warn', message: any, queryRunner?: QueryRunner) {
    switch (level) {
      case 'info': {
        mysqlLogger.info(message);
        break;
      }
      case 'warn': {
        mysqlLogger.warn(message);
      }
    }
  }
}

3. environments

多环境配置

// env.dev.ts
import {ConnectionOptions} from 'typeorm';

export const db: ConnectionOptions = {
  type: 'mysql',
  host: 'localhost',
  port: 3307,
  username: 'root',
  password: '123456',
  database: 'test',
  logging: true,
  // synchronize: true,
  timezone: '+08:00',
  dateStrings: true,
  entities: ['../**/*.entity.ts']
};
// env.prod.ts
import {ConnectionOptions} from 'typeorm';

export const db: ConnectionOptions = {
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: '123456',
  database: 'test',
  logging: true,
  timezone: '+08:00',
  dateStrings: true,
  entities: ['../**/*.entity.ts']
};
// index.ts 根据不同环境导出配置,需要其他环境,可自行添加环境文件,修改下面代码
import * as dev from './env.dev';
import * as prop from './env.prop';

const env = process.env.NODE_ENV;

let environment = dev;

if (env !== 'development') {
  environment = prop;
}

export {environment};

4. utils

// crypto.ts 密码加密
import * as Crypto from 'crypto';

export function cryptoPassword(pwd: string, key: string) {
  return Crypto.createHmac('sha256', key).update(pwd).digest('hex');
}

5. constants

// index.ts
export const JWT_SECRET = 'huzz-koa-server';  // jwt 秘钥
export const NO_AUTH_PATH = {
  path: [/\//, /\/register/, /\/login/] // 公共接口,jwt排除的路由
};

6. middleware

  1. 定义错误处理的中间件
    // error.middleware.ts
    import {Context} from 'koa';
    import {logger} from '../core/logger';
    
    export async function errorHandle(ctx: Context, next: () => Promise<any>) {
      try {
        await next();
      } catch (err) {
        if (!err.code) {
          logger.error(err.stack);
        }
        ctx.body = {
          code: err.code || -1,
          message: err.message.trim()
        };
        ctx.status = 200; // http 状态码设为200,让前端不报错
      }
    }
    
  2. 定义http请求响应的中间件,统一响应格式
    // resoponse.middleware.ts
    import {Context} from 'koa';
    
    export async function responseHandle(ctx: Context, next: () => Promise<any>) {
      if (ctx.result !== undefined) {
        ctx.type = 'json';
        ctx.body = {
          code: 0,
          data: ctx.result,
          message: 'ok'
        };
      }
      await next();
    }
    
  3. 定义logger中间件,记录http请求的日志
    // logger.middleware.ts
    import {Context} from 'koa';
    import {logText, logger} from '../core/logger';
    
    export async function loggerHandle(ctx: Context, next: () => Promise<any>) {
      const start = Date.now();
      await next();
      const end = Date.now();
      const ms = end - start;
      const log = logText(ctx, ms);
      logger.info(log);
    }
    
  4. 定义jwt验证的中间件
    import * as koaJwt from 'koa-jwt';
    import {JWT_SECRET, NO_AUTH_PATH} from '../constants';
    
    export const jwt = koaJwt({
      secret: JWT_SECRET
    }).unless(NO_AUTH_PATH); // 排除公共路由
    
    

7. entities

数据库对应实体

// user/user.entity.ts 创建用户实体
import {Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn} from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number; // ts严格模式下添加非空断言 "!"

  @Column()
  username!: string;

  @Column()
  password!: string;

  @Column()
  nickname!: string;

  @CreateDateColumn()
  createTime!: Date;

  @UpdateDateColumn()
  updateTime!: Date;
}

// user/user.model.ts 创建实体模型
import {User} from './user.entity';
import {getRepository, Repository} from 'typeorm';
import {cryptoPassword} from '../../utils/crypto';
import {Injectable} from 'koa-route-decors'; // 导入Injectable装饰器,申明该类可被注入

@Injectable()
export class UserModel {
  private repository: Repository<User>;
  private select: (keyof User)[] = ['id', 'username', 'nickname'];

  constructor() {
    this.repository = getRepository(User);
  }

  async create(user: User) {
    const result = await this.repository.save(user);
    return result;
  }

  async findById(id: number) {
    const user = await this.repository.findOne(id, {select: this.select});
    return user;
  }

  async findByUsername(username: string) {
    const user = await this.repository.findOne({username}, {select: this.select});
    return user;
  }

  async findAndCheckPassword(username: string, password: string) {
    const user = await this.repository.findOne({username, password: cryptoPassword(password, username)}, {select: this.select});
    return user;
  }

  async findAll() {
    const users = await this.repository.find({select: ['id', 'username', 'nickname']});
    return users;
  }

}

8. components,处理http请求

  1. account 账号组件,
    // account.controller.ts 控制器,处理http的逻辑
    import {Context} from 'koa';
    import {Post, Controller} from 'koa-route-decors';
    import * as jwt from 'jsonwebtoken';
    import {JWT_SECRET} from '../../constants';
    import {AccountService} from './account.service';
    
    @Controller()
    export class AccountController {
      constructor(private accountService: AccountService) {}
      @Post()
      async register(ctx: Context, next: () => Promise<any>) {
        const {username, password, nickname} = ctx.request.body;
        const result = await this.accountService.insert(username, password, nickname);
        ctx.result = {
          id: result.id,
          username: result.username,
          nickname: result.nickname
        };
        await next();
      }
    
      @Post()
      async login(ctx: Context, next: () => Promise<any>) {
        const {username, password} = ctx.request.body;
        // 验证密码并生成token
        const user = await this.accountService.verifyPassword(username, password);
        const token = jwt.sign({username: user.username, id: user.id}, JWT_SECRET, {expiresIn: '30d'});
        ctx.result = {
          id: user.id,
          username: user.username,
          nickname: user.nickname,
          token
        };
        await next();
      }
    }
    // account.service.ts 处理实体模型相关的逻辑
    import {UserModel} from '../../entities/user/user.model';
    import {CustomError} from '../../core/error';
    import {User} from '../../entities/user/user.entity';
    import {cryptoPassword} from '../../utils/crypto';
    import {Injectable} from 'koa-route-decors';
    
    @Injectable()
    export class AccountService {
      constructor(private userModel: UserModel) {}
    
      async insert(username: string, password: string, nickname: string = '') {
        const exist = await this.userModel.findByUsername(username);
        if (exist) {
          throw new CustomError(-1, '用户已存在');
        }
        const user = new User();
        user.username = username;
        user.password = cryptoPassword(password, username);
        user.nickname = nickname;
        const result = await this.userModel.create(user);
        return result;
      }
    
      async verifyPassword(username: string, password: string) {
        const user = await this.userModel.findAndCheckPassword(username, password);
        if (user) {
          return user;
        } else {
          throw new CustomError(-1, '用户名或密码错误');
        }
      }
    }
    
  2. user 用户组件,和账号组件类似

其他和http相关的逻辑,扩展components

9. 应用启动类

// app.ts
import 'reflect-metadata';
import * as Koa from 'koa';
import * as cors from '@koa/cors';
import * as body from 'koa-body';
import * as staticService from 'koa-static';
import * as Router from 'koa-router';
import {autoRouter} from 'koa-route-decors';
import {loggerHandle} from './middleware/logger.middleware';
import {errorHandle} from './middleware/error.middleware';
import {responseHandle} from './middleware/response.middleware';
import {jwt} from './middleware/jwt.middleware';
import {resolve} from 'path';
import {connection} from './database';

export class App {
  private app: Koa;
  constructor() {
    this.app = new Koa();
    this.init().catch(err => console.log(err));
  }

  // 装配各种中间件
  private async init() {
    const router = new Router();
    const subRouter = await autoRouter(resolve(__dirname, './'));
    router.use(subRouter.routes(), jwt); // 路由添加jwt验证
    this.app
      .use(cors())
      .use(loggerHandle)
      .use(errorHandle)
      .use(body({
        multipart: true
      }))
      .use(router.routes())
      .use(router.allowedMethods())
      .use(staticService(resolve(__dirname, '../../static')))
      .use(responseHandle);
  }

  start(port: number) {
    this.app.listen(port, () => {
      connection();
      console.log('service is started');
    });
  }
}

10. bin/www启动应用

单独拆分出www主要是为了,分离服务层和应用层的逻辑

// www
const env = process.env.NODE_ENV;

let App = null;
if (env === 'development') {
  App = require('../src/app/app').App;
} else {
  App = require('../dist/app/app').App;
}

const app = new App();

app.start(8080); // 应用成功启动,监听8080端口

尾巴

到这里整个koa项目架构就搭建完毕了,可能有些同学会发现没用配置路由,这是由于使用koa-route-decors库实现的用装饰器模式自动配置路由,想了解具体实现可以查看我的另外一篇文章用装饰器的语法写koa路由,这个库同时实现了控制器的依赖注入。
本项目已经上传到github,可点击链接查看源代码huzz-koa-tempalte

你可能感兴趣的:(nodejs项目的正确打开方式,typescript + koa)