Koa-spring:後端太忙,讓我本身寫服務(上)

關於這篇文章

  • Node在個人工做實際應用中,具體的業務界限;
  • 選型的思考
  • Koa-spring究竟是個什麼概念;
  • 中間件的應用
若是你感興趣,能夠fork項目,本身體驗一下
Koa-springhttps://github.com/closertb/k...
related-client: https://github.com/closertb/k...

萬事開頭難

在轉型前端前,我是一個Java練習生(Servlet,SSH,SpringMvc都只會照着寫),嗯,真的是練習生。幾年後,又走上了接口開發的老路,雖然這不是本身第一次用Node(先前,去淌過SSR的水:初探SSR,React + Koa + Dva-Core),但寫接口服務,這仍然是黃花閨女上花轎:頭一回。雖然看過,聽過不少大佬將Node運用(BFF,SSR)到業務,延伸大前端的業務覆蓋範圍,但本身仍是對界限,Node承擔的角色有不少疑惑,爲此,還去脈脈上發了個動態,指望大佬指點迷津。但本身的路,真的只有本身知道那個路口是出口。
image
最後鑑於這是一個測試用的內部系統,就肯定前端頁面接口所有直接對接數據庫;登陸,權限,日誌做爲中間層對接公司的公共服務。肯定完邊界後,開始糾結框架選型。雖然本身私下都是用Koa,但感受離實際運用到業務,仍是缺乏必定的便捷性。後面又接觸到EggJs,Nest,routing-controllers。EggJS是阿里內部的專用Node框架,成熟天然不言而喻,但對我來講,框架過重,但裏面不少思想是值得借鑑的。NestJs和本身指望的很近,風格和SpringMvc很是類似,官方文檔看似也比較全,但同時製造了不少概念,和Egg同樣,過重,也許沒選它也和只支持Express有關吧。routing-controllers給人的感受就剛恰好,SpringMvc的開發風格、Koa的中間件機制,自由發揮,一見傾心的感受。前端

工程搭建

主框架:routing-controllers + Koa

import {Controller, Param, Body, Get, Post, Put, Delete} from "routing-controllers";

// 路由相較於示例,有點小改動
@Controller('/user')
export class UserController {

    @Get("/query")
    getAll() {
       return "This action returns all users";
    }

    @Get("/query/:id")
    getOne(@Param("id") id: number) {
       return "This action returns user #" + id;
    }

    @Post("/save")
    post(@Body() user: any) {
       return "Saving user...";
    }

    @Put("/update/:id")
    put(@Param("id") id: number, @Body() user: any) {
       return "Updating a user...";
    }

    @Delete("/delete/:id")
    remove(@Param("id") id: number) {
       return "Removing user...";
    }

}

routing-controllers是一個相對於Egg和Nest較小衆的庫。 git

image

迭代較慢,三年時間纔到0.8.0的版本,沒有官網,只有Readme。但這些絲絕不掩蓋其易擴展的品質,routing-controllers的引入,未改變Koa的洋蔥模型中間件機制和錯誤捕獲機制,結合Typedi,也能作到Nest框架的效果。下圖是本身使用後整理的routing-controllers中間件機制。github

image
全局中間件和路由局部中間件,我以爲設計是十分巧妙的,這對解決通用問題,是及其有效的,在後面的中間件一節會具體分析。官方提供的Demo,也能夠下載運行一下試試。spring

Model:數據庫操做:Sequelize

頁面接口直接對接數據庫,因此但願選擇一個相似JPA,Hibernate這樣的ORM框架,簡化Sql操做,可選項很少,也沒作多少對比,最後選擇了Sequelize,結合sequelize-typescript,也收穫了一個不錯的開發體驗,下面的代碼就是一個日誌模型的聲明:typescript

import { Table, Column, Model } from 'sequelize-typescript';
import { toTimeStamp } from '../../config/common';

@Table({ tableName: 'change_logs' })
export default class ChangeLog extends Model<ChangeLog> {
  @Column
  userId: string; // 用戶Id

  @Column
  update_type: string; // 更新表

  @Column
  update_id: number; // 更新表的Id

  @Column
  before!: string; // 字段更新前

  @Column
  after!: string; // 字段更新後

  @Column
  get update_time(): number  { // 更新時間,轉時間戳
    return toTimeStamp(this, 'update_time');
  }
}

下面一段代碼就是Sequelize的基本CUR操做,看起也是十分便捷的,這裏出現了幾個自定義的裝飾器,在後面會專門講到:數據庫

export default class Repository {
  private model = Model;

  @validWithPagination
  findAll(body: object = {}) { // 列表查詢
    return this.model.findAll({
      where: body
    });
  }

  findOne(id: number) { // 詳情查詢
    return this.model.findOne({
      where: {
        id
      }
    });
  }
  @validBody
  update(body: AnyObject) { // 更新
    const { id, ...others } = body;
    return this.model.update({
      ...others,
    }, {
      where: {
        id
      }
    });
  }
  save(body: Model) { // 新增
    return body.save();
  }
}

Sequelize帶給我惟一的困惑就是,其默認返回的響應體,是一個被他的Model類封裝過的數據集,提及來有點抽象,看下面的響應實例: 後端

指望響應體api

{ 
  create_time: 1575642055000,
  update_time: 1576380905000,
  id: 5,
  scene_code: 'special',
  param_code: 'bit',
  param_name: '任何',
  param_type: 'string',
  operator_add: 'SYS',
  is_delete: 0
}

實際響應體:太長,截取部分跨域

// Rule
{
  dataValues:
   { id: 5,
     scene_code: 'special',
     param_code: 'bit',
     param_name: '任何',
     param_type: 'string',
     operator_add: 'SYS',
     is_delete: 0,
     create_time: 2019-12-06T14:20:55.000Z,
     update_time: 2019-12-15T03:35:05.000Z
  },
  _modelOptions:
  { 
    timestamps: false,
    validate: {},
    freezeTableName: true,
    underscored: false,
    ...
  }
  ...
}

看起只須要拿響應體的dataValues就是咱們指望的響應體,但這個響應體是相關getter屬性方法並無執行。官方也提供了{ query: { raw: true }}這個設置去得到簡單的響應體,但也有一樣的問題,getter屬性未執行。看了一下官方實現,getter方法是在調用toJson方法時,纔會執行(迷惑不解臉)。promise

中間層服務的處理:

在實現登陸,權限,日誌,存儲做爲中間層對接公司的公共服務時,Node須要發起請求,並響應包裝轉發出去,這裏選擇了比較成熟的request和request-promise庫。

數據校驗:

雖然這是一個內部系統,除了前端提交作校驗外,業務方仍是但願接口層要有一些必要的校驗。若是所有用If-else寫,想一想這仍是一個比較大的工做量的,不過還好,有class-validator這個庫的存在,加上裝飾器的寫法,仍是比較簡潔。好比下面這個登陸表單的校驗示例:

import { MinLength, Length } from "class-validator";

export default class User {
  @Length(6, 12)
  name: string;

  @MinLength(6)
  pwd: string;
}

語言:Typescript

看上面那麼多,你應該猜到了,這個項目選擇了Typescript。

中間件

在個人項目中涉及到多箇中間件,既有全局中間件,好比鑑權,響應體包裝,錯誤處理;又有局部路由中間件,好比操做日誌,分頁。

全局中間件-鑑權:AuthCheckMiddleWare

routing-controllers提供了鑑權認證機制,但操做起來不方便,須要每一個路由去添加標誌。因此本身實現了鑑權中間件,全局中間件都繼承於KoaMiddlewareInterface,須要區分是路由響應前,仍是響應後。鑑權中間件的目的是驗證每個請求,是否有操做權限,驗證token的有效性。這裏的實現是一種簡易的形式,只檢查了本地緩存信息,未到用戶中心繼續驗證,供參考:

import { Middleware, KoaMiddlewareInterface } from "routing-controllers";
import * as cache from 'memory-cache';

@Middleware({ type: "before" }) // before 表示在請求路由響應前
export default class AuthCheckMiddleWare implements KoaMiddlewareInterface {
    async use(ctx: any, next: any): Promise<any> {
      const { request: { body = {}, query = {}, path } } = ctx;
      const { uid, token } = Object.assign({}, query, body);
      // 在用戶登陸時,會以Uid存儲當前用戶的信息,有效期20分鐘
      const user = cache.get(uid);

      // 若是是非登陸,檢查攜帶的token是否和緩存的token一致
      if(path === '/user/login' || (user && user.token === token)) {
        if (path !== '/user/login') {
          ctx.user = user; // 將user信息掛載到當前請求體
        }
        await next();
      } else {
        ctx.body = {
          code: '120001',
          message: uid ? 'Session過時,請從新登陸' : '請先登陸',
          status: 'fail'
        };
      }
    }
}

全局中間件須要在生成koa實例時,進行註冊:

const koaApp = createKoaServer({
        cors: true, // 這裏開啓了Cors跨域
        controllers: [__dirname + '/services/*/controller.js'],
        middlewares: [AuthCheckMiddleWare],
    });

局部路由中間件-操做記錄:RecordMiddleWare

操做日誌中間件,其目的是記錄某些表的數據新增,修改操做。須要記錄下字段修改前和修改的值,操做類型及操做人。若是按常規思惟,在每個須要記錄操做的路由Controller去加入日誌記錄代碼。代碼冗餘,且日誌記錄需求變更時,是一件很是被動的事情,因此局部路由中間件是最好的實現方式,在須要記錄的路由加入這個中間件便可。

import Model from '../services/changeLog/model';
import { AnyObject } from '../config/interface';

/**
 * 新增修改操做日誌記錄,入庫。
 * @param ctx 
 * @param next 
 */
export default async function RecordMiddleWare(ctx: any, next: (err?: any) => Promise<any>): Promise<any> {
  const { user = {}, body: { before, after, update_type, id } } = ctx;
  const old: AnyObject = {};
  const nw: AnyObject = {}; // 最新數據
  if (!before) {
    Object.assign(nw, after);
  } else {
    // 記錄比較,只保存改變過的值的修改記錄
    Object.keys(after).forEach((prop) => {
      // 數字比較時,因爲請求體,數字會被轉化成字符串,因此這裏用了==,來自動轉換數據類型
      if (before[prop] == after[prop]) {
        return;
      }
      old[prop] = before[prop];
      nw[prop] = after[prop];
    });
  }
  // 重寫body
  ctx.body = { msg: 'success', id };
  await next();
  const repository = new Model({
    update_id: id,
    update_type,
    userId: user.id || 'SYS', // 獲取userId
    after: JSON.stringify(nw),
    before: JSON.stringify(old)
  });
  repository.save()
}

在規則數據更新時,加入操做日誌記錄中間件

import { JsonController, Post, Body, UseAfter } from "routing-controllers";
import { Service } from "typedi";
import RecordMiddleWare from '../../middlewares/RecordMiddleWare';
import RuleRepository from "./repository";
import { AnyObject } from '../../config/interface';

@Service()
@JsonController('/rule')
export default class RuleController {
  @Post("/update")
  @UseAfter(RecordMiddleWare)
  async update(@Body() body: AnyObject) {
    const { id } = body;
    const before = await this.ruleRepository.findOne(id);
    await this.ruleRepository.update(body);
    return { before, after: body, id, update_type: 'rule' };
  }
}

總結

這一篇主要講了koa-spring的一些庫應用及項目實現方式,這裏不得不強力推廣routing-controllers與sequelize-typescript這兩個庫,Thanks to @RobinBuschmann for answering my issue so patient(maybe you can't understand what i write or say, just accept my thanks)。感嘆一句,寫Demo和實際應用到業務真的是天差地別,在下一篇,將會談一些深刻的優化和疑難點解決,主要關於:

  • 自定義裝飾器
  • 繼承的應用
  • 多進程通訊
提早預告: Koa-spring:後端太忙,讓我本身寫服務(下)
相關文章
相關標籤/搜索