【Node.js】Expressのroutesをいい感じにする（ディレクトリ・ファイルを追加するだけでrouteに登録されるようにする）

## はじめに

Expressで実装する際、以下のようにrouterの実装を`src/routes`などに分けて実装する事はままあると思う。ただこの実装だと、`routes`を増やすたびに`app.user()`も必要になり、手間かつ冗長に思える。

```js:./src/index.js
import express from 'express';

// 省略
import router from './routes/index';
import shopsRouter from './routes/shops';
import searchRouter from './routes/search';
import accountRouter from './routes/account';

// 省略
const app = express();

// 省略
app.use('/account', accountRouter);
app.use('/search', searchRouter);
app.use('/shops', shopsRouter);
app.use('/', router);

// 省略
```
```console
$ tree src/
src/
├── routes
│   ├── account.js
│   ├── account.reviews.js
│   ├── index.js
│   ├── search.js
│   └── shops.js
├── index.js
...
```

そこで今回は、新規にrouterを追加したい場面では、フォルダとファイルを追加すれば、あとは自動でrouterが登録されるような構造を目指して実装してみたいと思う。

※ソースコード全体は以下

https://github.com/yuta-katayama-23/node-express/commit/ce1c189bb256f5bb869fa607c8e9752079cbe535

## 実際にやってみる

`routes`に新規でフォルダを追加し、そのフォルダ内に各routerの実装を行っていくような実装ができるようにする。

### `./src/routes`以下のフォルダを読み取り、各`routes`を登録する関数をもつオブジェクトとして返す

実際の実装を見る方がイメージが湧くと思われるので、実装をしてみると以下のようになる。

```js:src/routes/index.js
import fs from 'fs';
import appRoot from 'app-root-path';
import { camelCase } from 'lodash';

export default async () => {
	const routes = {};

	const directoryDirents = fs
		.readdirSync(appRoot.resolve('src/routes'), { withFileTypes: true })
		.filter((dirent) => !dirent.isFile());

	await Promise.all(
		directoryDirents.map(async (dirent) => {
			const module = await import(`./${dirent.name}`);
			routes[camelCase(dirent.name)] = module.default;
		})
	);

	return routes;
};
```
```js:src/index.js
// 省略
import createRoutes from './routes';

// 省略
const routes = await createRoutes();
console.log(routes);

// 省略
```
```console
$ tree src/
src/
├── index.js
...
├── routes
│   ├── index.js
│   └── user
│       └── index.js
```

上記のようにすると、`console.log(routes)`の結果としては、以下のように何らかの関数がフォルダ名をキーにしたオブジェクトがログに出ている事が分かる。

```console
{ user: [Function: __WEBPACK_DEFAULT_EXPORT__] }
```

こうすると上記のフォルダ構成のように、`src/routes`以下にAPIのパスに関連したフォルダを配置する事で、そのフォルダ内のindex.jsに書かれている処理を実行できるようになるので、この後出てくるが、routeを`app.use()`で登録するなどができるようになる。

実装に関して少し補足する。

- fs.readdirSync(appRoot.resolve('src/routes'), { withFileTypes: true }).filter((dirent) => !dirent.isFile())
ここでは、`src/routes`以下のフォルダを全部読み取るという事をしている（ファイルは`!dirent.isFile()`で除外している）
- await import(\`./${dirent.name}\`)
各フォルダのindex.js（例えば、`src/routes/user/index.js`）をimportしている
- routes[camelCase(dirent.name)] = module.default
ここで、`routes`オブジェクトにフォルダ名をcameCaseにしたものをキーにして、importしたモジュールのdefault（`export default`したモジュール）を設定している

次に、`src/routes/user/index.js`を実装して、Expressのroutesに登録する部分の実装をやってみたいと思う。

### `src/routes`のExpressのroutesに登録する

具体的な実装を見る方が分かりやすいと思われるので、実装を先に見ていく。

```js:src/routes/user/index.js
import { strict as assert } from 'assert';
import fs from 'fs';
import appRoot from 'app-root-path';
import { parse } from 'path';

const BASE_V1_API_PATH = `/api/v1`;

export default async (options = {}) => {
	assert.ok(options.app, 'app must be required');
	const { app } = options;

	const fileDirents = fs
		.readdirSync(appRoot.resolve('src/routes/user/v1'), { withFileTypes: true })
		.filter((dirent) => dirent.isFile());

	await Promise.all(
		fileDirents.map(async (dirent) => {
			const pathName = parse(dirent.name).name;
			const module = await import(`./v1/${dirent.name}`);

			app.use(`${BASE_V1_API_PATH}/${pathName}`, module.default);
		})
	);
};
```
```js:src/routes/user/v1/user.js
import { Router } from 'express';

const router = Router();

router.post('/', async (req, res) => {
	// 省略
});
router.get('/:id', async (req, res) => {
	// 省略
});

// 省略

export default router;
```
```js:src/index.js
// 省略
const routes = await createRoutes();
await Promise.all(Object.keys(routes).map((route) => routes[route]({ app })));
// 省略
```
```console
$ tree src/
src/
├── index.js
...
├── routes
│   ├── index.js
│   └── user
│       ├── index.js
│       └── v1
│           ├── user.js
│           └── users.js
```

上記のような実装をする事で、`export default (options = {}) => {...}`の関数が、`src/index.js`の方で実行されるので、最終的に`app.use('/api/v1/user', user.jsでexportされているrouter)`が実行され、Expressのroutesにパスが登録されるようになる。

実装に関して少し補足する。

- fs.readdirSync(appRoot.resolve('src/routes/user/v1'), { withFileTypes: true }).filter((dirent) => dirent.isFile())
`src/routes/user/v1`以下のファイルを一覧で取得している（汎用性という意味では、ここの`src/routes/user/v1`の部分をフォルダごとに書き換える必要が出てくるのがあと一つな感じもする・・・）
- parse(dirent.name).name
Node.jsの組み込みモジュールである[pathのparse](https://nodejs.org/docs/latest-v16.x/api/path.html#pathparsepath)を利用して、拡張子を除いたファイル名を取得している（このファイル名がAPIのパス名にもなるようにしている）
- await import(`./v1/${dirent.name}`)
`src/routes/user/v1`以下のモジュール（今回だとrouter）を読み込み、`app.use()`でExpressのroutesに登録できるようにしている

最終的には以下のようなBefore・Afterになり、Expressのroutes自体は変更前と変わらず登録されている事も確認できる。

https://github.com/yuta-katayama-23/node-express/commit/ce1c189bb256f5bb869fa607c8e9752079cbe535

```console
study@localhost:~/workspace/node-express (main *)
$ node dist/index.js

  ♻️  Server running at:
    - Local:   http://localhost:3000

  🔀 Api routes found:
    - /api/v1/user:             POST      
    - /api/v1/user/:id:         GET, PATCH
    - /api/v1/user/id/:userId:  GET       
    - /api/v1/users:            GET       
    - /api/409/error:           GET       
    - *:                        GET
```

## まとめとして

今回はExpressのroutesへの登録（ルーティングの設定）の実装で、冗長になりがちな部分を解消するための実装を試しにやってみた。ディレクトリ・ファイルを配置するだけ（正確には読み込むパスだけは書き換えが必要だが）で、特に意識することなくExpressのroutesにAPIを登録できるようになった。

ディレクトリ・ファイルを読み込む処理が失敗すればサーバー自体も起動しないのでアプリケーションの動作自体には影響がないという面もあるのではないかと思った。

どなたかの何かの役に立てば幸いです。

## Tips
### Webpackでトランスパイル（Build）している場合、`top-level-await`でエラーになるので[experiments](https://webpack.js.org/configuration/experiments/)を設定する

top-level-awaitを利用しようとすると、デフォルトで配下のようなエラーが出る。

```
Module parse failed: The top-level-await experiment is not enabled (set experiments.topLevelAwait: true to enabled it)
```

そのため、[experiments](https://webpack.js.org/configuration/experiments/)に書かれている通り、webpackのconfigに以下のような設定を追加する必要がある。

```js
module.exports = {
	// 省略
	experiments: {
		topLevelAwait: true
	},
	// 省略
};
```