Node.jsでWebフレームワークといえばExpressがデファクトスタンダートですが、近年NestJSというTypeScriptベースでデコレーターやDIを使ったフルスタックなWebフレームワークが猛威を振るっています。
GitHubにおけるスターの数も、Expressに迫っています。
本題のDenoに話を移すと、DenoにおけるWebフレームワークは色々と立ち上がり始めている様ですが、こちらの記事を参照する限りだとExpressっぽいフレームワークであるoakのスター数が最も多そうです。
そこでこのoakにデコレーターとDIを導入し、NestJSっぽくAPIサーバーを作ってみようと思います。
手順
下記の流れで実装していきます。
- HTTPリクエストメソッドを指定する
Get
デコレーターを作る - パスを指定するための
Controller
デコレーターを作る - Routerを設定するための
createRouter
ファンクションを作る - DIを導入する
- tsconfig.jsonを更新する
実装後のイメージは、こんな感じ。
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 });
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();
}
}
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を設定する初期化処理を実装します。
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
メソッドとpath
とroute
の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に下記の設定を追加します。
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
そのため、コマンド実行時には-c
オプションでファイルを指定する必要があります。
$ deno run --allow-net -c tsconfig.json main.ts
まとめ
これで実装は完了です。
ここまでのコードをcore.ts
としてまとめてimportすると、冒頭の「実装後のイメージ」のように実装できます。
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 });
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();
}
}
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