前言
前两章,我们一起学习了物料、用户系统的设计与开发,在经过了用户系统与物料系统的折磨之后,大家应该对 NestJS 已经非常的熟悉了,学习旅途也到了网关系统中最关键与核心的功能模块开发。
由于物料与网关核心功能的耦合度非常高,操作起来非常麻烦,毕竟我们没有真实的界面,所以在本章内容中,我们会使用 mock 数据来实现代理转发的功能,同时对缓存数据做一个大概的介绍。
网关核心系统开发
拦截路由
在需求分析中我们提到了,网关基础服务作为所有资源的前置入口,需要对所有的请求进行拦截,再根据请求的类型分发到对应的服务或者返回需求的资源,所以我们需要一个接受所有请求的 Controller。
新建 src/core/intercepter.controller.ts 如下所示
import { Public } from '@/auth/constants';
import {
Controller,
Get,
Req,
Res,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
@Controller()
export class IntercepterController {
constructor() { }
@Get()
async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
res.send('html')
}
}注意,此时的
getApp引入了@Res() res: FastifyReply,不能直接return返回值,需要使用res.send来返回html格式
新建 src/core/intercepter.module.ts,并在 app.module.ts 中导入。
import { Module } from '@nestjs/common';
import { IntercepterController } from './intercepter.controller';
@Module({
controllers: [IntercepterController],
})
export class IntercepterModule { }然后请求接口 http://localhost/api (为了方便后期修改 DNS 测试本地域名,可以将项目启动端口改成 80),可以得到如下返回值。
从图上看出,请求路径是携带了 api 前缀的,并不符合拦截全部路由的要求,可以修改 main.ts 中的 setGlobalPrefix 方法:
- app.setGlobalPrefix('api');
+ app.setGlobalPrefix('api', { exclude: ['*'] });同时再修改 src/core/intercepter.controller.ts 中的 getApp 的 Get 配置:
- @Controller()
+ @Controller('*')
export class IntercepterController {
constructor() { }
@Get()
async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
res.send('html')
}
}然后再访问如下路由对比即可以发现,当访问到项目已存在的接口时,会正常走之前的业务逻辑,当访问不存在的业务逻辑路由时,将统一进入 IntercepterController 中:
解析路由
首先,我们需要根据域名来匹配不同的返回页面,在上一步已经将项目启动端口修改为 80,所以直接修改系统的 host 目录,来修改域名 DNS 解析,使之指向本地服务,然后浏览器访问即可:
127.0.0.1 www.cookieboty.com
127.0.0.1 nginx.cookieboty.com
127.0.0.1 jenkins.cookieboty.com
127.0.0.1 gitlab.cookieboty.com
127.0.0.1 devops.cookieboty.com
127.0.0.1 fe.cookieboty.com@Controller('*')
export class IntercepterController {
constructor() { }
@Get()
async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
- res.send('html')
+ res.send(req.headers.host)
}
}如上图所示,我们可以通过 req.headers.host 来拿到对应的域名来判断返回资源,但是仅仅有域名肯定是不足够的。
通常情况下,一个域名下面会存在多个前端项目,这些前端项目可以通过路由前缀来区分,例如 www.cookieboty.com/devops 、www.cookieboty.com/jenkins 等等,所以我们也需要对整个 url 进行解析。
同时,也存在 SPA 项目中使用 history 的情况,这样的话就会存在虚拟路由,真实的访问地址与浏览器请求的地址不匹配的情况,我们也需要模拟 Nginx 中的 try_files 模式。
第一步:借助 url 库来组装路由
+ import { URL } from 'url';
export class IntercepterController {
constructor() { }
@Get()
async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
+ const urlObj = new URL(req.url, `http://${req.headers.host}`);
+ console.log('urlObj===>', urlObj)
res.send(req.headers.host)
}
}访问之前的域名可以在控制台得到如下的结构:
可以看到控制台中有两种打印,普通的
html会自动请求favicon资源,我们只需要拦截正常的请求,过滤掉favicon.ico这种类型的请求即可,或者返回一个通用的小图标也行。
第二步:修改 IntercepterController,添加读取 html 方法与判断空异常:
@Controller()
export class IntercepterController {
constructor(private readonly intercepterService: IntercepterService) { }
@Get('*')
@Public()
async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const urlObj = new URL(req.url, `http://${req.headers.host}`);
if (urlObj.pathname === '/favicon.ico') return res.send('ico');
const html = await this.intercepterService.readHtml(urlObj);
if (!html) return res.send('404');
res.headers({
'Content-Type': 'text/html',
});
res.send(html);
}
}第三步:新建 src/core/intercepter.service.ts 添加 IntercepterService
import { Injectable } from '@nestjs/common';
import { WebSiteDataModel } from './types';
import { getMatchedSync } from './intercepter';
import { ConfigService } from '@nestjs/config';
import * as WebsitesMock from './websites_mock.json';
import * as FilesMock from './files_mock.json';
@Injectable()
export class IntercepterService {
constructor() { }
get websites(): Record<string, WebSiteDataModel> {
return WebsitesMock as Record<string, WebSiteDataModel>
}
async readHtml(urlObj: URL) {
const { data: matchedData } = getMatchedSync(urlObj, this.websites);
if (!matchedData) return null
const html = FilesMock[matchedData.pageId]
return html;
}
}files_mock.json
{
"1": "devops",
"2": "jenkins",
"3": "nginx"
}websites_mock.json
{
"www.cookieboty.com": {
"/devops": {
"lastModified": 1,
"pageId": 1
},
"/jenkins": {
"lastModified": 1,
"pageId": 2
},
"/nginx": {
"lastModified": 1,
"pageId": 3
}
}
}第四步:创建解析 url 的方法,解析路由地址,例如将 devops/list、devops/detail 等路由全部指向到根路由地址 devops 的资源上,在第三步中的 getMatchedSync 方法就用作此判断:
export const getMatchedSync = (
urlObj: URL,
websites: Record<string, WebSiteDataModel> = {},
): { path: string | undefined; data: PageModelItem | undefined } | undefined => {
if (!urlObj.hostname) {
return undefined;
}
const website = matchWebsite(urlObj.hostname, websites);
if (!website) {
return undefined;
}
const { data, path } = matchPath(website, urlObj.pathname);
if (!data) {
return { path: undefined, data: undefined };
}
return { data, path };
}先由 matchWebsite 来匹配 host,获取匹配成功的 host 数据之后,再使用 matchPath 方法进行 path 的匹配:
export const matchWebsite = (
host: string,
websites: Record<string, WebSiteDataModel>,
): WebSiteDataModel | undefined => {
return websites[host];
}
export const matchPath = (
website: WebSiteDataModel | undefined,
targetPath: string,
): { path: string; data: PageModelItem } | undefined => {
if (!website) return;
const targetPathArr = splitPath(targetPath);
if (targetPathArr.find((i) => i === '*')) {
throw new Error(
'[matchPath] website custome path include *, redirect to 404',
);
}
// 全匹配
if (website[targetPath]) {
return {
path: targetPath,
data: website[targetPath],
};
}
// .html 后缀 且 不等于 index.html,
if (/\/[^\/]+\.html$/.test(targetPath) && !/\/index\.html/.test(targetPath)) {
return {
path: targetPath,
data: website[targetPath],
};
}
// 通配
let matchLen = 0;
let resultKey: string;
Object.keys(website.path || {}).forEach((path) => {
if (!path.startsWith('/')) path = `/${path}`;
const pathArr = splitPath(path);
// 非必须容错:仅允许最后一个字符出现 *
if (pathArr.slice(0, -1).find((i) => i === '*'))
throw new Error('[matchPath] path include *');
/**
* 遍历路由规则列表,匹配命中立即停止遍历
*/
let currentMatchLen = 0;
let currentResultKey: string;
for (let i = 0; i < pathArr.length; i += 1) {
if (targetPathArr[i] !== pathArr[i]) {
currentMatchLen = 0;
currentResultKey = undefined;
return;
} else if (undefined === targetPathArr[i]) {
currentMatchLen = 0;
currentResultKey = undefined;
return;
}
currentMatchLen = i + 1;
currentResultKey = path;
}
if (matchLen < currentMatchLen) {
matchLen = currentMatchLen;
resultKey = currentResultKey;
}
});
return {
path: resultKey,
data: website.path[resultKey],
};
}获取资源
在解析路由的第三步中,大家应该注意到在路由匹配中,有 2 个 mock json 文件 websites_mock.json 与 files_mock.json,它是由物料系统中的 pages 组成的,具体的结构为:
/**
* @description 站点数据模型
*/
export interface WebSiteDataModel {
/**
* @description 站点下的所有 path 表
*/
[host: string]: {
[path: string]: PageModelItem;
}
}
export interface PageModelItem {
/**
* @description 最后修改时间
*/
lastModified?: number;
/**
* @description 页面 id
*/
pageId?: number;
/**
* @description 权限
*/
permissions?: Array<() => (boolean | Promise<boolean>) | boolean>;
}正常情况下,我们是需要通过 pageId 去数据库查询出对应的资源返回,不过在 mock 的情况省略了,现在我们一起来看看结果如何:
注意 http://www.cookieboty.com/jenkins/list 与 http://www.cookieboty.com/jenkins 这两个路由,它就是之前所提到过的虚拟路由匹配,当访问的资源为
SPA项目使用history构建的话,jenkins之后所有的路径都需要强制指向jenkins这个路由上。
缓存
由于我们是静态资源代理,所以为了达到最快的访问速度,给用户提供最高的性能体验,可以借助 3 层缓存来实现。
第一层缓存:由客户端自身在访问之后产生的协商缓存,当请求资源不变的情况下,用户访问的是本地资源,这个知识点,大部分的前端同学都应该掌握的非常熟悉,这里就不再拓展了。接下来介绍一下,在我们的项目中如何利用缓存来提高访问效率。
如上图所示,第二层缓存与第三层缓存分别是程序运行本地服务器与 Redis 服务。
当第一个用户在访问页面时,如果在本地没有查询到资源的话,会向 Redis 服务请求资源,当 Redis 服务也没有请求到对应的资源的话,最后再去请求 MongoDB 获取。
同样在每一次请求到资源的情况下,都会在对应的层级缓存资源,这样任一一个用户访问资源之后,就会产生缓存数据,这样可以减少数据库的读写,同时提高响应速度。
可能有同学说 Redis 这一层可以省略,但一般网关服务也会使用分布式部署方式,在分布式架构中你命中的服务不一定是在本地有缓存了,所以即使丢失本地缓存,也不能舍弃 Redis,当任一的服务命中资源之后,都会在 Redis 中产生缓存,其他的服务也可以共享缓存数据。
另外在本地缓存中,由于会存储大量的文件,所以也会存在旧版资源冗余的情况,所以在之前的设计中,永远都只保存最新的资源产物,不会保留历史产物,通过 lastModified 参数来判断需要更新资源。
当资源过多的情况下也可以使用 LRU 算法来清空本地资源,看需求进行功能拓展即可,大家尽情发挥,不用客气。
在缓存的工具选择上,大家可以选择自己熟悉的工具即可,只是
NestJS自带的缓存插件对接Redis比较方便,并不代表你一定要使用Redis才行,比如我们公司目前的缓存使用的是Nacos。
写在最后
本章的代码地址为 feat/core,需要的同学自取,会持续更新。
由于篇幅所限,文章里面提到的开发内容比较少,只有最核心的两个功能,其他的功能可以等待完整的项目出来之后再对比学习即可,一般关键的地方我会做必要的注释,如果还有其他的问题可以加群讨论或者直接联系我都行。
到目前为止,我们已经陆陆续续开发 3 个大的功能模块,大家应该能感觉到目前的工程已经很庞大了,如果是普通开发模式的话,每一次的重启速度已经变慢。
整个项目目前已经有 40+ 个接口,如果物料系统再复杂点的话,已经 50+ 的接口不在话下。而这只是 Controller 的数量,并未括工具类与 Service 层的代码。
所以在接下来下一章,我们将对这个逐渐变成巨石的工程进行项目拆分,降低项目之间的耦合度,做到独立部署与独立开发。
如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏
