Nest 静态文件服务与 404 的处理
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"}⏎