3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Deno]デコレーターとDIを導入してoakをNestJSっぽく書く

Last updated at Posted at 2021-12-24

Node.jsでWebフレームワークといえばExpressがデファクトスタンダートですが、近年NestJSというTypeScriptベースでデコレーターやDIを使ったフルスタックなWebフレームワークが猛威を振るっています。
GitHubにおけるスターの数も、Expressに迫っています。

全画面_2021_12_25_1_41.png

本題のDenoに話を移すと、DenoにおけるWebフレームワークは色々と立ち上がり始めている様ですが、こちらの記事を参照する限りだとExpressっぽいフレームワークであるoakのスター数が最も多そうです。

そこでこのoakにデコレーターとDIを導入し、NestJSっぽくAPIサーバーを作ってみようと思います。

手順

下記の流れで実装していきます。

  1. HTTPリクエストメソッドを指定するGetデコレーターを作る
  2. パスを指定するためのControllerデコレーターを作る
  3. Routerを設定するためのcreateRouterファンクションを作る
  4. DIを導入する
  5. tsconfig.jsonを更新する

実装後のイメージは、こんな感じ。

./main.ts
import { Application } from "https://deno.land/x/oak/mod.ts";
import { createRouter } from "./core.ts";
import { FooController } from "./foo/foo.controller.ts";
import { FooService } from "./foo/foo.service.ts";

const app = new Application();

export const router = createRouter({
  controllers: [FooController],
  providers: [FooService],
  routePrefix: "v1",
});
app.use(router.routes());

await app.listen({ port: 8000 });
./foo/foo.controller.ts
import { Controller, Get } from "../core.ts";
import { FooService } from "./foo.service.ts";

@Controller("foo")
export class FooController {
  constructor(private readonly fooService: FooService) {}

  @Get("bar")
  bar() {
    return this.analysisService.bar();
  }
}
./foo/foo.service.ts
import { Injectable } from "https://deno.land/x/inject/mod.ts";

@Injectable()
export class FooService {
  bar() {
    return { value: "bar", status: "ok" };
  }
}

実装

1. HTTPリクエストメソッドを指定するGetデコレーターを作る

oakでrouterを登録する際に対象のHTTPリクエストメソッドを指定できるように、HTTPリクエストメソッドを対象のファンクション名やパスなどと併せてActionMetadataインターフェースに沿ってMetadataに登録します。
Metadataの登録には、reflect_metadataというモジュールを用います。
ついでにGet以外のHTTPリクエストメソッドもサポートしておきます。

import { Reflect } from "https://deno.land/x/reflect_metadata/mod.ts";

type HTTPMethods = "get" | "put" | "patch" | "post" | "delete";
interface ActionMetadata {
  path: string;
  method: HTTPMethods;
  functionName: string;
}
const ACTION_KEY = Symbol("action");

function mappingMethod(method: HTTPMethods) {
  return (path = "") =>
    (target: any, functionName: string, _: PropertyDescriptor) => {
      const meta: ActionMetadata = { path, method, functionName };
      addMetadata(meta, target, ACTION_KEY);
    };
}

function addMetadata<T>(value: T, target: any, key: symbol) {
  const list = Reflect.getMetadata(key, target);
  if (list) {
    list.push(value);
    return;
  }
  Reflect.defineMetadata(key, [value], target);
}

export const Get = mappingMethod("get");
export const Post = mappingMethod("post");
export const Put = mappingMethod("put");
export const Patch = mappingMethod("patch");
export const Delete = mappingMethod("delete");

2. パスを指定するためのControllerデコレーターを作る

orkでは下記のように子routerを親routerに紐づけることができるので、Controllerデコレーターではpathとrouterを設定する初期化処理を実装します。

main.ts
import { Application, Router } from "https://deno.land/x/oak/mod.ts";

const router = new Router();

const path = /* 対象のパス */;
const childRouter = /* 対象のRouter */;
router.use(path, childRouter.routes(), childRouter.allowedMethods());

const app = new Application();
app.use(router.routes());

await app.listen({ port: 8000 });

初期化処理の中で、先程の登録したHTTPリクエストメソッドのメタデータと合わせてあとでまとめてRouterに登録できるようにrouteを定義し、getterで取得できる様にしておきます。
合わせてpathもgetterで取得できる様にしておきます。

interface ActionMetadata {
  path: string;
  method: HTTPMethods;
  functionName: string;
}
const ACTION_KEY = Symbol("action");

export function Controller<T extends { new (...instance: any[]): Object }>(
  path: string
) {
  return (fn: T): any =>
    class extends fn {
      private _path?: string;
      private _route?: Router;

      init(routePrefix?: string) {
        const prefix = routePrefix ? `/${routePrefix}` : "";
        this._path = `${prefix}/${path}`;
        const route = new Router();
        const list: ActionMetadata[] = Reflect.getMetadata(
          ACTION_KEY,
          fn.prototype
        );
        list.forEach((meta) => {
          (route as any)[meta.method](
            `/${meta.path}`,
            (context: RouterContext<string>) => {
              context.response.body = (this as any)[meta.functionName](context);
            }
          );
          console.log(`Mapped: [${meta.method.toUpperCase()}]${this.path}/${meta.path}`);
        });

        this._route = route;
      }

      get path(): string | undefined {
        return this._path;
      }

      get route(): Router | undefined {
        return this._route;
      }
    };
}

ちなみにpathには、initメソッドで渡されるroutePrefixでprefixを設定できる様になっています。

3. Routerを設定するためのcreateRouterファンクションを作る

最初の実装イメージにあった、Routerを設定するためのcreateRouterを作成します。
併せてrouterのpath登録時にPrefixを設定できる様にもしておきます。
呼び出し方法は下記のイメージ。

export const router = createRouter({
  controllers: [FooController],
  routePrefix: "v1",
});

Controllerデコレータで登録されたコントローラにはinitメソッドとpathrouteのgetterが存在しているので、createRouterではそれらを使ってrouterを設定していきます。

export interface CreateRouterOption {
  controllers: any[];
  routePrefix?: string;
}

export const createRouter = ({
  controllers,
  providers,
  routePrefix,
}: CreateRouterOption) => {
  const router = new Router();
  controllers.forEach((Controller) => {
    const controller = new Controller();
    controller.init(routePrefix);
    const path = controller.path;
    const route = controller.route;
    router.use(path, route.routes(), route.allowedMethods());
  });
  return router;
};

4. DIを導入する

injectモジュールのInjectableデコレーターを用いてDIの対象を登録するので、登録されたMetadataを取得した上で、ControllerにDIします。
先程のcreateRouterは下記に様になります。

import { bootstrap } from "https://deno.land/x/inject/mod.ts";

export interface CreateRouterOption {
  controllers: any[];
  providers: any[];
  routePrefix?: string;
}

export const createRouter = ({
  controllers,
  providers,
  routePrefix,
}: CreateRouterOption) => {
  const router = new Router();
  controllers.forEach((Controller) => {
    // const controller = new Controller(); コメントアウト
    Reflect.defineMetadata("design:paramtypes", providers, Controller); // ここを追加
    const controller = bootstrap<any>(Controller); // ここを追加
    controller.init(routePrefix);
    const path = controller.path;
    const route = controller.route;
    router.use(path, route.routes(), route.allowedMethods());
  });
  return router;
};

5. tsconfig.jsonを更新する

TypeScriptでデコレーターを使うために、tsconfig.jsonに下記の設定を追加します。

tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

そのため、コマンド実行時には-cオプションでファイルを指定する必要があります。

$ deno run --allow-net -c tsconfig.json main.ts 

まとめ

これで実装は完了です。
ここまでのコードをcore.tsとしてまとめてimportすると、冒頭の「実装後のイメージ」のように実装できます。

./main.ts
import { Application } from "https://deno.land/x/oak/mod.ts";
import { createRouter } from "./core.ts";
import { FooController } from "./foo/foo.controller.ts";
import { FooService } from "./foo/foo.service.ts";

const app = new Application();

export const router = createRouter({
  controllers: [FooController],
  providers: [FooService],
  routePrefix: "v1",
});
app.use(router.routes());

await app.listen({ port: 8000 });
./foo/foo.controller.ts
import { Controller, Get } from "../core.ts";
import { FooService } from "./foo.service.ts";

@Controller("foo")
export class FooController {
  constructor(private readonly fooService: FooService) {}

  @Get("bar")
  bar() {
    return this.analysisService.bar();
  }
}
./foo/foo.service.ts
import { Injectable } from "https://deno.land/x/inject/mod.ts";

@Injectable()
export class FooService {
  bar() {
    return { value: "bar", status: "ok" };
  }
}

起動すると、routerに登録されたpathがログに出力されます。

$ deno run --allow-net -c tsconfig.json main.ts 
Check file:///Users/xxx/xxx/main.ts
Mapped: [GET]/v1/foo/bar

NestJSっぽくするにはパラメーターを受け取るためのデコレーターやGuardの実装など色々と足りないものがありますが、それっぽい実装になりました。
今後さらに機能を追加して、NestJSっぽくしてみようと思います。

[追記]
機能を少し拡充してoak_decoratorsという名前でライブラリを公開しました。
使い方はこちらの記事を参照。
https://qiita.com/biga816/items/11b39a86fd6be6aeb3de

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?