前言

查了很多资料,大家都是业务发展到一定程度了,才需要一个API网关服务来统一处理鉴权、限流等功能。
不过我这边没一个服务呢,搞一个API网关纯属自娱自乐,搞得也不专业,只是顺带学习点后端知识。
特别鸣谢ChatGPT老师提供的技术支持🎉🎉🎉

开始搭建项目

首先安装Nest.js

1
2
$ npm i -g @nestjs/cli
$ nest new project-name

不得不说用了Nest.js之后感觉Koa什么的都是小打小闹。真搞大型项目还得这种高度结构化的框架。

不过回过头来看着眼前这简陋的网关设计和空荡荡的服务接入列表,不禁想起otto那著名的采访
- 你觉得你是大型项目吗?
- 我觉得我是。

接下来安装Consul,这边使用Consul来提供服务发现。

1
2
3
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install consul

验证安装

1
consul --version

创建配置文件
Consul 使用的默认配置目录是 /etc/consul.d/,可以在这里放置多个配置文件。

1
2
3
4
5
6
7
8
9
10
11
{
"datacenter": "dc1",
"node_name": "node1",
"server": true,
"bootstrap_expect": 1,
"data_dir": "/var/consul",
"bind_addr": "0.0.0.0",
"client_addr": "0.0.0.0",
"advertise_addr": "192.168.1.100",
"ui": true
}

这里主要设置了绑定的ip地址,使用0.0.0.0让任意ip都可以连接。然后把ui字段设为了true,这样就有后台界面了

consul默认端口是8500,访问localhost:8500就能看到它的后台了

健康检查

Consul需要配置健康检查的接口以确定服务是正常的

安装依赖

1
pnpm i @nestjs/terminus @nestjs/axios

编写HealthController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus';

@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService, // 自动注入 HealthCheckService
private http: HttpHealthIndicator,
) {}

@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.http.pingCheck('nestjs-docs', 'https://docs.nestjs.com'),
]);
}
}

导入

1
2
3
4
5
6
7
8
9
10
11
12
13
// app.module.ts

import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { HealthController } from './health.controller';


@Module({
imports: [TerminusModule, HttpModule], // 导入 TerminusModule和HttpModule
controllers: [HealthController],
})
export class AppModule {}

启动项目查看健康检查是否生效

1
http://localhost:3000/health

连接Consul

安装依赖

1
npm install @nestjs/microservices consul
  • @nestjs/microservices:Nest.js 的微服务模块,用于服务发现和注册。
  • consul:用于和 Consul 交互的库。

consul以及当前服务的配置,网关服务和子服务都可以使用这一套代码连接consul

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// consul.service.ts

import { Injectable } from "@nestjs/common"

import * as Consul from "consul"

@Injectable()
export class ConsulService {
private consul: Consul.Consul

constructor() {
this.consul = new Consul({
host: "127.0.0.1", // consul服务地址

port: "8500", // consul服务端口

promisify: true,
})
}

async registerService() {
const serviceName = "cowboy-hat-server"

await this.consul.agent.service.register({
id: serviceName,

name: serviceName,

address: "localhost", // 服务地址

port: 3000, // 服务端口

check: {
http: "http://localhost:3000/health", // 健康检查 URL

interval: "10s", // 健康检查间隔

timeout: "5s", // 健康检查超时
},
} as any)

console.log(`服务 ${serviceName} 成功注册到Consul.`)
}

async deregisterService() {
const serviceName = "cowboy-hat-server"

await this.consul.agent.service.deregister(serviceName)

console.log(`服务 ${serviceName} 注销.`)
}
}

启动与consul的连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// consule.module.ts

import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common"

import { ConsulService } from "./consul.service"

@Module({
providers: [ConsulService],
})
export class ConsulModule implements OnModuleInit, OnModuleDestroy {
constructor(private readonly consulService: ConsulService) {}

async onModuleInit() {
await this.consulService.registerService()
}

async onModuleDestroy() {
await this.consulService.deregisterService()
}
}

写了个网关的Controller
如果已经有了多个服务注册在consul上
那么可以通过注册的服务名找到其他服务

1
2
3
4
5
6
export class GatewayController {
constructor(private readonly consulService: ConsulService) {}
async getInstances(serviceName: string) {
const instances = await this.consulService.getServiceInstances(serviceName)
}
}

instances是个数组因为服务可能运行在多个实例上
选择一个实例,也可以考虑实现一个简单的负载均衡

1
const instance = instances[0]

转发请求

给网关Controller加上/api前缀
这里用url参数匹配了/api后面的路径作为服务名去找服务
访问主服务的所有/api路径开头的api都会经过一层转发给子服务
例如/api/auth,那么就会去找名为auth的子服务,这需要子服务在上面注册服务的代码里设置name字段为”auth”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Controller("api")
export class GatewayController {
constructor(
private readonly consulService: ConsulService,

private readonly httpService: HttpService,
) {}

@All(":serviceName/*")
async handleRequest(
@Param("serviceName") serviceName: string,

@Req() req: Request,

@Res() res: Response,
) {
const path = req.params[0] // 获取路径

const instances = await this.consulService.getServiceInstances(serviceName)

if (instances.length === 0) {
res.status(404).send("找不到服务")

return
}

const instance = instances[0] as any

// 这里协议头后面考虑区分环境,本地就用http,生产再强制https
const targetUrl = `http://${instance.ServiceAddress}:${instance.ServicePort}/${path}`


// 转发请求
try {
const response = await lastValueFrom(
this.httpService.request({
method: req.method,

url: targetUrl,

headers: { ...req.headers, "content-length": undefined },

data: req.body,

timeout: 5000,
}),
)

res.header(response.headers).status(response.status).send(response.data)
} catch (error) {
res

.status(error.response?.status || 500)

.send(error.message || error.response?.data || "Internal server error")
}
}
}

鉴权

写一个守卫,作用是从header中读取到jwt token,然后再解析jwt是否正确。
不正确的话直接返回401。正确的话我这里选择将jwt携带的uid挂在转发的header里方便子服务读取
公司里的网关就是这么设计的,所以刚来的时候出现鉴权问题后端经常问我有没有带鉴权的Header,我看了半天控制台发送的请求就是没有,还以为我哪里搞错了。后来才知道这个Header是网关加的,前端根本不用处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}

private extractTokenFromHeader(request: Request): string | undefined {
const cookie = request.headers.cookie;
const token = parseCookies(cookie)[CookieName.COWBOY_HAT];
return token;
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (whitelist.includes(`${request.method}:${request.url}`)) {
return true;
}

const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
request.headers[HeaderName.CH_USER] = payload.sub;
} catch (e) {
console.error(e);
throw new UnauthorizedException();
}
return true;
}
}

接下来给网关的Controller加上这层守卫,这样所有转发的api都需要鉴权了。

1
2
3
4
5
@Controller('api')
@UseGuards(AuthGuard)
export class GatewayController {
// ...
}

至于那些不需要鉴权的api,我这边写了个白名单,在白名单里的直接方向。我觉得做成支持通配更好,这个后面再说吧。

1
2
3
4
5
6
7
8
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (whitelist.includes(`${request.method}:${request.url}`)) {
return true;
}

// ...
}