Skip to content

仲灏小栈

仲灏约 1 分钟

前言

前两章,我们一起学习了物料、用户系统的设计与开发,在经过了用户系统与物料系统的折磨之后,大家应该对 NestJS 已经非常的熟悉了,学习旅途也到了网关系统中最关键与核心的功能模块开发。

由于物料与网关核心功能的耦合度非常高,操作起来非常麻烦,毕竟我们没有真实的界面,所以在本章内容中,我们会使用 mock 数据来实现代理转发的功能,同时对缓存数据做一个大概的介绍。

网关核心系统开发

拦截路由

在需求分析中我们提到了,网关基础服务作为所有资源的前置入口,需要对所有的请求进行拦截,再根据请求的类型分发到对应的服务或者返回需求的资源,所以我们需要一个接受所有请求的 Controller

新建 src/core/intercepter.controller.ts 如下所示

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 中导入。

ts
import { Module } from '@nestjs/common';

import { IntercepterController } from './intercepter.controller';

@Module({
  controllers: [IntercepterController],
})

export class IntercepterModule { }

然后请求接口 http://localhost/api为了方便后期修改 DNS 测试本地域名,可以将项目启动端口改成 80),可以得到如下返回值。

image.png

从图上看出,请求路径是携带了 api 前缀的,并不符合拦截全部路由的要求,可以修改 main.ts 中的 setGlobalPrefix 方法:

diff
- app.setGlobalPrefix('api');
+ app.setGlobalPrefix('api', { exclude: ['*'] });

同时再修改 src/core/intercepter.controller.ts 中的 getAppGet 配置:

diff
- @Controller()
+ @Controller('*')
export class IntercepterController {
  constructor() { }
  @Get()
  async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
    res.send('html')
  }
}

然后再访问如下路由对比即可以发现,当访问到项目已存在的接口时,会正常走之前的业务逻辑,当访问不存在的业务逻辑路由时,将统一进入 IntercepterController 中:

image.png

解析路由

首先,我们需要根据域名来匹配不同的返回页面,在上一步已经将项目启动端口修改为 80,所以直接修改系统的 host 目录,来修改域名 DNS 解析,使之指向本地服务,然后浏览器访问即可:

yaml
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
diff
@Controller('*')
export class IntercepterController {
  constructor() { }
  @Get()
  async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
-    res.send('html')
+    res.send(req.headers.host)
  }
}

image.png

如上图所示,我们可以通过 req.headers.host 来拿到对应的域名来判断返回资源,但是仅仅有域名肯定是不足够的。

通常情况下,一个域名下面会存在多个前端项目,这些前端项目可以通过路由前缀来区分,例如 www.cookieboty.com/devops 、www.cookieboty.com/jenkins 等等,所以我们也需要对整个 url 进行解析。

同时,也存在 SPA 项目中使用 history 的情况,这样的话就会存在虚拟路由,真实的访问地址与浏览器请求的地址不匹配的情况,我们也需要模拟 Nginx 中的 try_files 模式。

第一步:借助 url 库来组装路由

diff
+ 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)
  }
}

访问之前的域名可以在控制台得到如下的结构:

image.png

可以看到控制台中有两种打印,普通的 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

ts
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

json
{
  "1": "devops",
  "2": "jenkins",
  "3": "nginx"
}

websites_mock.json

json
{
  "www.cookieboty.com": {
    "/devops": {
      "lastModified": 1,
      "pageId": 1
    },
    "/jenkins": {
      "lastModified": 1,
      "pageId": 2
    },
    "/nginx": {
      "lastModified": 1,
      "pageId": 3
    }
  }
}

第四步:创建解析 url 的方法,解析路由地址,例如将 devops/listdevops/detail 等路由全部指向到根路由地址 devops 的资源上,在第三步中的 getMatchedSync 方法就用作此判断:

ts
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],
  };
}

获取资源

在解析路由的第三步中,大家应该注意到在路由匹配中,有 2mock json 文件 websites_mock.jsonfiles_mock.json,它是由物料系统中的 pages 组成的,具体的结构为:

ts
/**
 * @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 的情况省略了,现在我们一起来看看结果如何:

image.png

注意 http://www.cookieboty.com/jenkins/listhttp://www.cookieboty.com/jenkins 这两个路由,它就是之前所提到过的虚拟路由匹配,当访问的资源为 SPA 项目使用 history 构建的话,jenkins 之后所有的路径都需要强制指向 jenkins 这个路由上。

缓存

由于我们是静态资源代理,所以为了达到最快的访问速度,给用户提供最高的性能体验,可以借助 3 层缓存来实现。

第一层缓存:由客户端自身在访问之后产生的协商缓存,当请求资源不变的情况下,用户访问的是本地资源,这个知识点,大部分的前端同学都应该掌握的非常熟悉,这里就不再拓展了。接下来介绍一下,在我们的项目中如何利用缓存来提高访问效率。

image.png

如上图所示,第二层缓存与第三层缓存分别是程序运行本地服务器与 Redis 服务。

当第一个用户在访问页面时,如果在本地没有查询到资源的话,会向 Redis 服务请求资源,当 Redis 服务也没有请求到对应的资源的话,最后再去请求 MongoDB 获取。

同样在每一次请求到资源的情况下,都会在对应的层级缓存资源,这样任一一个用户访问资源之后,就会产生缓存数据,这样可以减少数据库的读写,同时提高响应速度。

可能有同学说 Redis 这一层可以省略,但一般网关服务也会使用分布式部署方式,在分布式架构中你命中的服务不一定是在本地有缓存了,所以即使丢失本地缓存,也不能舍弃 Redis,当任一的服务命中资源之后,都会在 Redis 中产生缓存,其他的服务也可以共享缓存数据。

另外在本地缓存中,由于会存储大量的文件,所以也会存在旧版资源冗余的情况,所以在之前的设计中,永远都只保存最新的资源产物,不会保留历史产物,通过 lastModified 参数来判断需要更新资源。

当资源过多的情况下也可以使用 LRU 算法来清空本地资源,看需求进行功能拓展即可,大家尽情发挥,不用客气。

在缓存的工具选择上,大家可以选择自己熟悉的工具即可,只是 NestJS 自带的缓存插件对接 Redis 比较方便,并不代表你一定要使用 Redis 才行,比如我们公司目前的缓存使用的是 Nacos

写在最后

本章的代码地址为 feat/core,需要的同学自取,会持续更新。

由于篇幅所限,文章里面提到的开发内容比较少,只有最核心的两个功能,其他的功能可以等待完整的项目出来之后再对比学习即可,一般关键的地方我会做必要的注释,如果还有其他的问题可以加群讨论或者直接联系我都行。

到目前为止,我们已经陆陆续续开发 3 个大的功能模块,大家应该能感觉到目前的工程已经很庞大了,如果是普通开发模式的话,每一次的重启速度已经变慢。

整个项目目前已经有 40+ 个接口,如果物料系统再复杂点的话,已经 50+ 的接口不在话下。而这只是 Controller 的数量,并未括工具类与 Service 层的代码。

所以在接下来下一章,我们将对这个逐渐变成巨石的工程进行项目拆分,降低项目之间的耦合度,做到独立部署与独立开发。

如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏

上次更新: