基于TypeScript装饰器定义Express RESTful 服务

前言

本文主要讲解如何使用TypeScript装饰器定义Express路由。文中出现的代码经过简化不能直接运行,完整代码的请戳:https://github.com/WinfredWang/express-decorator

1 为什么使用装饰器

当我们在使用Express时,经常要暴露RESTful服务,代码如下:

var express = require('express');
var app = express();
app.get('/users', function(req, res) {
  res.send([{name:'xx'}]);
});

// 路由模块化写法
var router = express.Router();
app.get('/users', function(req, res) {
  res.send([{name:'xx'}]);
});

熟悉Java WEB童鞋知道jax-rs可以使用标注(annotation)声明服务。例:

@Path("/myResource")
public class SomeResource {
    @GET
    public String doGetAsPlainText() {
        ...
    }
 
    @GET
    public String doGetAsHtml() {
        ...
    }
}

使用这种方式声明的服务非常简洁方便,免去了写一坨重复代码之苦,而且看起来更加清晰,那我们看看在Node.js中如何做。


2 需求

参照jax-rs规范,我们列出如下需求:

  • 使用@Path声明RESTful服务路由
  • 使用@GET/@POST/@DELETE/@PUT声明子路由
  • 使用@PathParam,@QueryParam,@HeaderParam,@CookieParam,@FormParam,来接受服务参数

3 实现思路

在ES6和TypeScript中有新特性:装饰器(Decorator),正好我们可以借助它实现我们的需求。至于装饰器用法,可以参考我的上一篇文章

20180107195916

上图中左边是Java中定义RESTful代码,右边是Express代码,其实他们本质上是一一对应的。我们只要在装饰器的定义中实现Express 路由即可。

继续思考,我们Express 路由到底是放到那个注解中实现呢?
我们知道不同装饰器(类/方法/参数)执行顺序不同:

参数装饰器先执行,然后方法最后类装饰器

根据这个特性我们应该将核心实现放到类装饰器Path中执行是不是就可以了呢?

其实不是,我们看如下代码,我们在user-service.ts中定义了UserService服务。

@Path("/user")
 class UserService {
    @GET("/{id}")
    public getUsers(@PathParam("id") id: string) {
       // TODO
    }
 }

我们定义好了服务,然后想让Node.js模块加载,我们必须在工程入口模块(main.ts)中导入上述文件
main.ts代码:

import { HelloService } from './hello-service'

// TODO

上述服务代码会执行吗?也就是说
如果仅仅导入模块,而没有使用该模块的话,Node.js是否会加载这个模块呢,换句话说这个模块会执行吗?答案是NO。
为啥呀?因为Node.js对其做了优化,只有一个模块被真正用到才会加载。

上有政策,下有对策。我们就在模块引用一下。

import { HelloService } from './hello-service'

HelloService; // 就是为了让Node加载它

这样好吗,当然不好。谁知道这是干嘛的。

所以我们应该换了思路,将Express 注册路由代码拿到装饰器外部,额外提供注册服务的入口,通过该注册服务入口,用户可以显式看到有哪些服务。

import { HelloService } from './hello-service';
import {RegisterService } from 'xxx';

RegisterService([HelloService]);//注册服务

4 装饰器核心代码

基于上面的思考,我们在装饰器的实现中只是单纯地存储RESTful url以及参数即可,剩下服务注册工作交给RegisterService去做。

Path装饰器实现
 function Path(baseUrl: string) {
    return function (target) {
        target.prototype.$Meta = {
            baseUrl: baseUrl
        }
    }
}

这里我们将RESTful路由存储到类的原型中,以便服务实例化时能获取到。

GET/POST/DELETE/PUT
function GET (url: string) => {
    return (target, methodName: string, descriptor: PropertyDescriptor) => {
        let meta = getMethod(target, methodName);
        meta.subUrl = url;
        meta.httpMethod = httpMehod;
    }
}
QueryParam/PathParam等实现
function PahtParam(paramType: string) {
    return function (target, methodName: string, paramIndex: number) {
        let meta = getMethod(target, methodName);
        meta.params.push({
            name: paramName ? paramName : paramType,
            index: paramIndex,
            type: paramType
        });
    }
}

上述就装饰自身代码,本质上就是讲路由、http请求方法和参数存储到类的原型对象中,以便后续可以去到。

5 注册服务核心代码

路由实现

经过上面的分析,我们可知注册服务主要将Express中注册路由交由我们框架处理,核心代码如下:

function RegisterService(app, service) {
    let router = Router();

    // 1. 获取存储在原型对象中的http请求信息()
    let meta = getClazz(service.prototype);

    // 2. 实例化服务类
    let serviceInstance = new service();
    let routes = meta.routes;

    for (const methodName in routes) {
        let methodMeta = routes[methodName];
        let httpMethod = methodMeta.httpMethod;

        // 3. 回调函数
        let fn = (req, res, next) => {
            let result = service.prototype[methodName].apply(serviceInstance, params);
            res.send(result);
        };

        // 4. 注册路由
        router[httpMethod].apply(router, methodMeta.subUrl);
    }
    // 5. 路由中间件
    app.use.apply(app, [meta.baseUrl]);
}
image 6
http请求参数处理
 @GET('/:id', [ testMidware1 ])
 list( @PathParam('id') id: string, @QueryParam('name') name: string) {
    return {name:"tom", age: 10}
 }

用户编码时我们期望回调函数中的参数框架自动注入,而不是让用户自己从request中取,所以在注册服务代码中第3处,框架需要出更加参数装饰器中信息,从request中取值后注入回调函数中

// 3. 回调函数
let params = extractParameters(req, res, methodMeta['params']);
let fn = (req, res, next) => {
    let result = service.prototype[methodName].apply(serviceInstance, params);
    res.send(result);
};

// 根据参数类型,从request取出对应的值
function extractParameters(req, paramMeta) {
    let paramHandlerTpe = {
        'query': (paramName: string) => req.query[paramName],
        'path': (paramName: string) => req.params[paramName],
        'form': (paramName: string) => req.body[paramName],
        'cookie': (paramName: string) => req.cookies && req.cookies[paramName],
        'header': (paramName) => req.get(paramName),
        'request': () => req, // 获取request/response对象,做一些特别操作
        'response': () => res,
    }
    let args = [];
    params.forEach(param => {
        args.push(paramHandlerTpe[param.type](param.name))
    })
    
    return args;
}
response处理
 @GET('/:id', [ testMidware1 ])
 list( @PathParam('id') id: string, @QueryParam('name') name: string) {
    return {name:"tom", age: 10}
 }

一个服务处理完成后,总是要向浏览器返回值的,在回调函数中直接使用return语句,而不是自己调用response.send方法, 如下代码:

// 3. 回调函数
let fn = (req, res, next) => {
    let result = service.prototype[methodName].apply(serviceInstance, params);

    // 支持promise处理
    if (result instanceof Promise) {
        result.then(value => {
            !res.headersSent && res.send(value);
        }).catch(err => {
            next(err);
        });
    } else if (result !== undefined) {
        !res.headersSent && res.send(result);
    }
};

6 总结

以上就是我们框架处理核心代码,核心实现主要有两步:

  • 装饰器本身用来存在路由信息
  • 注册机制实现express路由注册(回调函数参数处理,返回值处理等)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,596评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,442评论 25 707
  • 2018年4月4星期三 今天因去县公司开会,上午放学没有安时接欣瑞。可是我还没到家,就给我打来了电说牙疼,并且疼的...
    永远幸福_cfea阅读 190评论 0 0
  • 许久, 没有自我! 想说的话,想做的事, 都默默的埋葬在心中。 忙忙碌碌, 碌碌无为…… 有你! 珍好! 接下来,...
    爱茶匠红豆阅读 151评论 0 0
  • 本节知识点 组件标签 模板标签用的`` 概述 <component></component>标签是vue自定义的标...
    我拥抱着我的未来阅读 3,359评论 2 0