くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

TypeScriptのデコレータを使って、expressのRoutingを楽にする

ExpressをつかってAPIサーバを作っていたけど、
Spring Bootみたいに、デコレータでマッピングしたいなと思い、
いろいろ調べてみたときの備忘録。

routing-controllersというライブラリもあるけど、デコレータ理解のためreflect-metadataを使ってます。

やりたいこと

こんな感じで、クラスのメソッドにデコレータをつけて、
パスを設定したい感じ。

// ./AuthController.ts
import { Request, Response } from "express";
import { Controller, Get, Post, SimpleController } from "./decorators";

@Controller("/auth")
class AuthController extends SimpleController {

  @Post("/login")
  public async login(req: Request, res: Response) {
    const data = "..."
    return res.json(data).end();
  }

  @Get("/me")
  public async me(req: Request, res: Response) {
    const data = "..."
    return res.json(data).end();
  }
}

export const authController = new AuthController();

あと、route()を生やして、
クラスにある全ルートを取得できるようにしたい。

// ./routers.ts
import { Router } from "express";
import { authController } from "./AuthController";
const _router = Router();
_router.use(authController.route());

export const router = _router;

やったこと

全体としてはこんな感じ。

  • SimpleController
    • @Controlerをつけるクラスの基底クラス
    • route()でrouterを取得できるようにするために用意
  • @Controller
    • クラスにつけるデコレータ
    • コンストラクタで@Getなどがついてるメソッドを収集・設定
  • @Get@Post@Patch@Delete
    • メソッドにつけるデコレータ
    • 設定しているURLパスとマッピングする

デコレータの取得には、reflect-metadataを使っていて、
以下のような感じで使っている。

  • メソッドのデコレータ(@Getなど)
  • クラスのデコレータ(@Controller)
// ./decorators.ts
import { Request, Response, Router } from "express";
import "reflect-metadata";

// @Controlerをつけるクラスの基底クラス
// route()を追加している
export abstract class SimpleController {
  baseRoute!: Router;
  path!: string;

  route() {
    const router = Router();
    router.use(this.path, this.baseRoute);
    return router;
  }
}

// reflect-metadataで利用する型定義
// Methodの種類や設定するメタデータの方を用意
type Method = "get" | "post" | "patch" | "delete";
interface RouteMetadata {
  path: string;
  method: Method;
  actionName: string;
}
const ACTION_KEY = Symbol("action");

// @Controlerの定義
// constructorを拡張して、メソッドからrouterを作成
export function Controller(path: string) {
  return (fn: new () => SimpleController) =>
    class extends fn {
      constructor() {
        super();
        // routerの作成
        const route = Router();
        // reflect-metadataを使って、アノテーション(メタデータ)を取得
        const list: RouteMetadata[] = Reflect.getMetadata(
          ACTION_KEY,
          fn.prototype
        );
        // アノテーション(メタデータ)ごとに対象のメソッドをラップして、
        // routerにパスを設定
        list.forEach((meta) => {
          const wrapFunc = async (req: Request, res: Response) => {
            const ret = await (this as any)[meta.actionName](req, res);
            return ret;
          };
          route[meta.method](meta.path, wrapFunc);
        });
        
        // 設定が終わったrouterと@Controlerのpathを保存
        this.baseRoute = route;
        this.path = path;
      }
    } as any;
}

// 各HTTPメソッドのデコレータの定義(@Get/@Postなど)
export const Get = mappingFactory("get");
export const Post = mappingFactory("post");
export const Patch = mappingFactory("patch");
export const Delete = mappingFactory("delete");

// HTTPメソッドを共通化した関数
function mappingFactory(method: Method) {
  return (path: string = "/") =>
    (target: any, actionName: string, dsc: PropertyDescriptor) => {
      // デコレータがついているメソッドに対する処理
      // reflect-metadataを使って、アノテーションのメタデータを設定
      const meta: RouteMetadata = { path, method, actionName };
      addMetadata(meta, target, ACTION_KEY);
    };
}

// アノテーションのメタデータを設定する処理
function addMetadata(metadataValue: RouteMetadata, target: any, metadataKey: Symbol) {
  const list = Reflect.getMetadata(metadataKey, target);
  if (list) {
    list.push(metadataValue);
    return;
  }
  Reflect.defineMetadata(metadataKey, [metadataValue], target);
}

クラスのデコレータ

クラスのデコレータは、クラスを返す関数になっている。
受け取ったconstructorを継承して、オーバーライドすることで、
任意の処理などを加えることができる。

// @reportableClassDecoratorデコレータ
function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    reportingURL = "http://www...";
  };
}
 
@reportableClassDecorator
class BugReport {
  type = "report";
  title: string;
 
  constructor(t: string) {
    this.title = t;
  }
}

・参考: Class Decorators

メソッドのデコレータ

メソッドのデコレータは、特定の引数をもつ関数を返す関数になっている。

  • target ... メソッドを定義しているクラス
  • propertyKey ... メソッド名
  • descriptor ... メソッドの情報
// @enumerableデコレータ
function enumerable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
 
  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

デコレータ内の処理は、呼び出し時に行われるわけでな無いので注意。
AOPのようにメソッドの前後に処理をはさみたい場合は、
descriptor.valueを書き換える必要がある。

function addlog(target: any, prop: string, desc: PropertyDescriptor) {
  const original = desc.value;
  desc.value = function () {
    const key = `${target.constructor.name}#${prop}`;
    console.log(`START: ${key}`);
    const ret = Reflect.apply(original, this, arguments);
    console.log(`END:   ${key}`);
    return ret;
  };
}

class Greeter {
  @addlog
  greet() { return "Hello"; }
}

// =>
// START: Greeter#greet
// Hello
// END:   Greeter#greet
デコレータの呼び出し順序

メソッドにデコレータを複数つけた場合、以下のような感じになる。

function f() {
  console.log("f(): evaluated");
  return function (target: any, prop: string, desc: PropertyDescriptor) {
    console.log("f(): called");
  };
}
function g() {
  console.log("g(): evaluated");
  return function (target: any, prop: string, desc: PropertyDescriptor) {
    console.log("g(): called");
  };
}

class C {
  @f()
  @g()
  foo() {
    console.log("foo");
  }
}

new C().foo();
// => 
// f(): evaluated
// g(): evaluated
// g(): called
// f(): called
// foo

以上!! なかなか難しいけど、いろいろできそう(´ω`)

参考にしたサイト様