Nest 静态文件服务与 404 的处理

准备工作

创建项目

为了更好地展示问题及解决方案,先创建个示例项目。

  • 安装工具
$ npm i -g @nestjs/cli
  • 生成项目
$ nest new nest-404-issue
  • 安装依赖

$ cd nest-404-issue && yarn
  • 启动项目
$ yarn start:dev

查看 Nest 正常的 404 返回

先来观察正常情况下,没做任何多余配置时,Nest 是如何处理 404 的。

创建示例接口:

src/app.controller.ts

import { Controller, Get, HttpException } from '@nestjs/common';
import { AppService } from './app.service';

@Controller('api')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('test')
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('exception')
  exception(): string {
    throw new HttpException('custom exception message', 403);
  }
}

测试上面的接口,以及测试一个不存在的地址,观察返回:

  • 接口正常返回
$ curl -i 'localhost:3000/api/test'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 12
ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
Date: Fri, 04 Sep 2020 09:38:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Hello World!⏎
  • 接口返回自定义异常
$ curl -i 'localhost:3000/api/exception'
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 55
ETag: W/"37-kvSF4OinnMiOnDYxCscPfTGVb5A"
Date: Fri, 04 Sep 2020 09:38:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":403,"message":"custom exception message"}
  • 访问不存在的地址,返回预期的 404
$ curl -i 'localhost:3000/api/xxx'
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 70
ETag: W/"46-MS/Zos7foeL1e/ldRUquWMPU3a0"
Date: Fri, 04 Sep 2020 09:38:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":404,"message":"Cannot GET /api/xxx","error":"Not Found"}

So far so good!

添加静态文件服务的能力

服务端,免不了需要 serve 静态文件。参考官方文档 不难开启。

先创建一个和 src 目录平级 public 目录,在其中放置静态文件,比如 public/foo.txt.

安装依赖:

$ yarn add @nestjs/serve-static

注册并配置静态文件服务的模块:

src/app.module.ts

import { Module } from '@nestjs/common';
+ import { ServeStaticModule } from '@nestjs/serve-static';
+ import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
+    ServeStaticModule.forRoot({
+      rootPath: join(__dirname, '..', 'public'),
+    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

到此文档里描述的就结束了,重启编译并启动,访问 localhost:3000/foo.txt 就能访问对应文件了。

$ curl -i 'localhost:3000/foo.txt'
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 04 Sep 2020 10:16:13 GMT
ETag: W/"14-174589dd6c4"
Content-Type: text/plain; charset=UTF-8
Content-Length: 20
Date: Fri, 04 Sep 2020 10:16:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5

content from foo.txt⏎

不过,如果静态文件目录不在根目录,比如,我们的静态文件是放置在 src/static 下,这种情况一般是希望静态文件也作为源码的一部分进行打包发布,即编译到 dist 目录中。

如下的目录结构展示了静态文件所在的位置:

  .
  ├── README.md
  ├── nest-cli.json
  ├── package.json
- ├── public
- │   └── foo.txt
  ├── src
  │   ├── app.controller.spec.ts
  │   ├── app.controller.ts
  │   ├── app.module.ts
  │   ├── app.service.ts
  │   ├── main.ts
+ │   └── static
+ │       └── blah.txt
  ├── test
  │   ├── app.e2e-spec.ts
  │   └── jest-e2e.json
  ├── tsconfig.build.json
  ├── tsconfig.json
  └── yarn.lock

src/app.module.ts

import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ServeStaticModule.forRoot({
-     rootPath: join(__dirname, '..', 'public'),
+     rootPath: join(__dirname, 'static'),
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

尝试访问该静态文件:

$ curl -i 'localhost:3000/blah.txt'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-rlKccw1E+/fV8niQk4oFitDfPro"
Date: Fri, 04 Sep 2020 10:20:17 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":500,"message":"Internal server error"}

同时日志中出现如下错误:

[Nest] 16319   - 09/04/2020, 6:20:17 PM   [ExceptionsHandler] ENOENT: no such file or directory, stat '/Users/wayou/Documents/dev/github/static-404-issue/dist/static/index.html' +7729ms
Error: ENOENT: no such file or directory, stat '/Users/wayou/Documents/dev/github/static-404-issue/dist/static/index.html'

注意,这里开始 Nest 已经没有展示 404 错误了,而是 500,这不是我们期望的。

这种情况下,需要做一些额外的工作才能正常访问静态文件。

配置 nest-cli.json,编译时将静态文件目录复制到 dist 下。

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": ["static/**/*"],
    "watchAssets": true
  }
}

注意这里 assets 中的路径是相对于上面 sourceRoot 的。

再次访问,成功了。

$ curl -i 'localhost:3000/blah.txt'
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 04 Sep 2020 10:23:27 GMT
ETag: W/"15-17458a47849"
Content-Type: text/plain; charset=UTF-8
Content-Length: 21
Date: Fri, 04 Sep 2020 10:23:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5

content form blah.txt⏎

404 问题

上面已经看到,在添加了静态文件服务后,访问不存在的地址时,服务端会报 500 而不是 404,同时会生产一条错误日志。

访问一个不存在的地址:

$ curl -i 'localhost:3000/xxx'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-rlKccw1E+/fV8niQk4oFitDfPro"
Date: Fri, 04 Sep 2020 10:28:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":500,"message":"Internal server error"}

Nest 日志:

[Nest] 16626   - 09/04/2020, 6:28:21 PM   [ExceptionsHandler] ENOENT: no such file or directory, stat '/Users/wayou/Documents/dev/github/static-404-issue/dist/static/index.html' +326110ms
Error: ENOENT: no such file or directory, stat '/Users/wayou/Documents/dev/github/static-404-issue/dist/static/index.html'

这里可以简单地补上该 index.html 文件,充当出错时的展示页面,但 http 状态码却是不够恰当的。

再比如现上机器被负载均衡进行健康检查时会访问 / 路径,如果程序中没有提供该路径,也会产生上述错误日志,但功能并不影响。

添加自定义 filter 解决 404 问题

为了解决上述问题,可以添加自定义的 filter,在该 filter 中捕获所有异常。

创建 filter 文件:

$ nest g filter not-found
CREATE src/not-found.filter.spec.ts (160 bytes)
CREATE src/not-found.filter.ts (185 bytes)

src/not-found.filter.ts

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class NotFoundFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const statusCode = HttpStatus.NOT_FOUND;
    if (exception.code === 'ENOENT') {
      Logger.log(exception);
      response.status(statusCode).json({
        statusCode,
        message: `Cannot ${request.method} ${request.url}`,
        error: 'Not Found',
      });
    } else {
      response.sendStatus(status);
    }
  }
}

使用该 filter:

src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NotFoundFilter } from './not-found.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new NotFoundFilter());
  await app.listen(3000);
}
bootstrap();

测试一个不存在的地址,404 正常返回:

$ curl -i 'localhost:3000/xxx'
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 66
ETag: W/"42-jFXjxTPwH3beSsrwe7cwiYAuVqg"
Date: Fri, 04 Sep 2020 10:37:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":404,"message":"Cannot GET /xxx","error":"Not Found"}

查看自定义的异常是否正常返回:

$ curl -i 'localhost:3000/api/exception'
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 9
ETag: W/"9-PatfYBLj4Um1qTm5zrukoLhNyPU"
Date: Fri, 04 Sep 2020 10:40:01 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Forbidden⏎

注意到这里在添加了自定义 filter 之后,返回的 body 和之前有区别,即没有错误信息了。可以修改上述 filter 把错误信息进行返回,不过线上环境不建议将服务端异常详情进行返回。

- response.sendStatus(status);
+ response.status(status).send(exception);

测试返回:

$ curl -i 'localhost:3000/api/exception'
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 89
ETag: W/"59-F2LsY9fA/4AwT+tZ4AN3mM6WbzA"
Date: Fri, 04 Sep 2020 10:42:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"response":"custom exception message","status":403,"message":"custom exception message"}

相关资源