NestJS搭建前端路由服務

記得要微笑發表於2022-02-18

背景

通常,為了更好地管理和維護專案,專案一般都會以業務範疇進行拆分,比如商品、訂單、會員等等,從而產生業務職責不同的眾多前端工程(SPA,單頁面應用)。假設現在有個需求,所有的前端工程都需要接入神策埋點Web JS SDK,如果採用每個前端工程靜態頁面index.html各自引入Web JS SDK的方案,那麼每個工程引入之後都需要重新部署一遍,並且以後需要更換第三方埋點SDK時,前面步驟需要重新來一遍,相當麻煩。而如果在訪問所有前端工程前面加一個路由轉發層,有點像前端閘道器,攔截響應,統一引入Web JS SDK,那麼就免去了很多簡單又容易出錯的工作量。

前端路由服務

前端路由服務.png

瀏覽器訪問單頁面應用其實就是獲取應用的index.html檔案解析渲染,閘道器會將請求轉發給前端路由服務(這裡前端路由服務是node服務,使用k8s註冊和發現),前端路由服務根據請求路由向源站(類似阿里雲物件儲存服務OSS,前端專案部署上面)獲取對應的index.html

瀏覽器載入解析html過程中,遇到linkscipt標籤時就會向CDN請求cssjs靜態資源,CDN上沒有才去回源

現在你可能產生一個疑問,為什麼html檔案不是向CDN獲取,而直接向源站獲取,不更慢了嗎?彆著急,後文會細細說來

七牛雲模擬實際專案部署

前端專案都會部署到物件儲存服務中,比如阿里雲物件儲存服務OSS,華為雲物件儲存服務OBS,這兒我使用七牛雲物件儲存服務模擬實際的部署環境

一、建立儲存空間,建立三級靜態資源目錄www/cassmall/inquiry,然後上傳一個index.html模擬實際專案部署

image-20220211201658075.png

二、給儲存空間配置源站域名和CDN域名(實際配置需要先給域名備案),請求index.html使用源站域名,請求jscssimg等靜態資源使用CDN域名

image-20220212182738052.png

這裡解釋一下為什麼到源站獲取index.html,而不是通過CDN域名獲取?假設通過CDN獲取index.html,當第一次部署單頁面應用,假設瀏覽器訪問http://localhost:3000/mall/inquiry/#/xxxCDN上沒有index.html則去源站拉取index.html,然後CDN快取一份;當對index.html做了修改,第二次部署(部署到源站),瀏覽器還是訪問http://localhost:3000/mall/inquiry/#/xxx,發現CDN上已經有index.html(舊),直接返回給瀏覽器,而不是返回源站最新的index.html,畢竟請求index.html的路徑版本號引數,會走CDN。如果直接使用源站域名請求index.html,那麼每次獲取到的都是最新index.html

其實,通過CDN域名獲取index.html也可以,不過需要設定CDN快取配置,讓其對html字尾的檔案不做快取處理。

推薦配置

另外,jscssimgvideo這類靜態資源我們希望頁面能夠快速載入,因此通過CDN加速獲取。jscss可能改動比較頻繁,但在構建後都會根據內容生成hash重新命名檔案,若檔案有更改,其hash也會變化,請求時不會命中CDN快取,會回源;若檔案沒有更改,其hash不會變化,則會命中CDN快取。imgvideo改動不會很頻繁,如需要改動,則重新命名上傳即可,防止同樣名稱命中CDN快取。

Nest搭建前端路由服務

專案建立

首先確定你已經安裝了Node.jsNode.js 安裝會附帶npx和一個npm 包執行程式。請確保在您的作業系統上安裝了Node.js (>= 10.13.0,v13 除外)。要建立新的Nest.js 應用程式,請在終端上執行以下命令:

npm i -g @nestjs/cli  // 全域性安裝Nest
nest new web-node-router-serve  // 建立專案

執行完建立專案, 會初始化下面這些檔案, 並且詢問你要是有什麼方式來管理依賴包:

image-20220210175913130.png

如果你有安裝yarn,可以選擇yarn,能更快一些,npm在國內安裝速度會慢一些

image-20220210180021849.png

接下來按照提示執行專案:

image-20220210184505958.png

專案結構

進入專案,看到的目錄結構應該是這樣的:

image-20220210184609655.png

這裡簡單說明一下這些核心檔案:

src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
app.controller.ts單個路由的基本控制器(Controller)
app.controller.spec.ts針對控制器的單元測試
app.module.ts應用程式的根模組(Module)
app.service.ts具有單一方法的基本服務(Service)
main.ts應用程式的入口檔案,它使用核心函式 NestFactory 來建立 Nest 應用程式的例項。

main.ts 檔案中包含了一個非同步函式,此函式將 引導(bootstrap) 應用程式的啟動過程:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

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

要建立一個 Nest 應用程式的例項,我們使用了 NestFactory 核心類。NestFactory 暴露了一些靜態方法用於建立應用程式的例項。其中,create() 方法返回一個應用程式的物件,該物件實現了 INestApplication 介面。在上面的 main.ts 示例中,我們僅啟動了 HTTP 偵聽器,該偵聽器使應用程式可以偵聽入棧的 HTTP 請求。

應用程式的入口檔案

我們調整一下入口檔案main.ts,埠可以通過命令輸入設定:

import { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

const PORT = parseInt(process.env.PORT, 10) || 3334; // 埠

async function bootstrap() {
  const app = await NestFactory.create<INestApplication>(AppModule);
  await app.listen(PORT);
}
bootstrap();

配置變數

專案需要設定的一些配置變數在全域性使用,使用config包來進行管理。安裝config

yarn add config

在根目錄下新建config目錄,目錄下新增default.jsdevelopment.jsproduction.js,新增如下配置:

  • default.js配置index.html請求路徑與真實在源站儲存路徑的對映關係
  • development.jsproduction.js配置了不同環境下源站的域名
// default.js
module.exports = {
  ROUTES: [
    {
      cdnRoot: 'www/cassmall/inquiry', // 源站靜態資源的儲存路徑
      url: ['/cassmall/inquiry'], // 請求路徑
    },
    {
      cdnRoot: 'www/admin/vip',
      url: ['/admin/vip'],
    },
  ],
};

// development.js
module.exports = {
  OSS_BASE_URL: 'http://r67b3sscj.hn-bkt.clouddn.com/', // 開發環境源站域名
};

// production.js
module.exports = {
  OSS_BASE_URL: 'http://r737i21yz.hn-bkt.clouddn.com/', // 生產環境源站域名
};

說一下config.get()獲取配置變數的規則:如果NODE_ENV為空,使用development.js,如果沒有development.js,則使用default.js; 若NODE_ENV不為空,則到config目錄中找相應的檔案,若檔案沒找到則使用default.js中的內容;若在指定的檔案中沒找到配置項,則去default.js找。

建立路由控制器

使用腳手架命令初始化專案,設定配置變數後,現在我們來建立路由控制層

// app.controller.ts
import {
  Controller,
  Get,
  Header,
  HttpException,
  HttpStatus,
  Req,
} from '@nestjs/common';
import { AppService } from './app.service';
import { Request } from 'express';
import config from 'config'; // esm方式引入cjs規範模組

type Route = { gitRepo: string; cdnRoot: string; url: string[] };
const routes = config.get('ROUTES'); // 獲取路由配置變數
const routeMap: { [key: string]: Route } = {};
routes.forEach((route) => {
  for (const url of route.url) {
    routeMap[url] = route;
  }
});

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

  @Get(Object.keys(routeMap))
  @Header('X-UA-Compatible', 'IE=edge,chrome=1')
  async route(@Req() request: Request): Promise<string> {
    const path = request.path.replace(/\/$/g, '');
    const route = routeMap[request.path];
    if (!route) {
      // 丟擲異常,Nest內建異常層會自動處理,生成JSON響應
      throw new HttpException(
        '沒有找到當前url對應的路由',
        HttpStatus.NOT_FOUND,
      );
    }
    // 獲取請求路徑對應的靜態頁面
    return this.appService.fetchIndexHtml(route.cdnRoot);
  }
}

esm引入cjs

第三方模組configcjs規範的模組,使用esm方式引入cjs之前需要在tsconfig.json新增配置:

{
  "compilerOptions": {
      "allowSyntheticDefaultImports": true, // ESM匯出沒有設定default,被引入時不報錯
    "esModuleInterop": true, // 允許使用ESM帶入CJS
  }  
}

當然你可以直接使用cjs規範引入const config = require('config')或者改成import * as config from 'config'引入,不然執行時會報下面錯誤:

image-20220210202810409.png

因為esm 匯入 cjsesmdefault 這個概念,而 cjs 沒有。導致匯入的config值為undefined

任何匯出的變數在 cjs 看來都是 module.exports 這個物件上的屬性,esmdefault 匯出也只是 cjs 上的 module.exports.default 屬性而已。設定esModuleInterop:true;tsc編譯時會給module.exports新增default屬性

// before
import config from 'config';

console.log(config);
// after
"use strict";

var _config = _interopRequireDefault(require("config"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

console.log(_config["default"]);

想了解這部分模組化處理,可以參考[tsc、babel、webpack對模組匯入匯出的處理](https://segmentfault.com/a/11...)

@Get接受路由路徑陣列

@Get() HTTP 請求方法裝飾器可以接受路由路徑陣列型別,告訴控制器可以處理哪些路由路徑的請求

/**
 * Route handler (method) Decorator. Routes HTTP GET requests to the specified path.
 *
 * @see [Routing](https://docs.nestjs.com/controllers#routing)
 *
 * @publicApi
 */
export declare const Get: (path?: string | string[]) => MethodDecorator;

異常處理

當路由配置沒有對應路由時丟擲異常,如果沒有自定義異常攔截處理,則Nest內建異常層會自動處理,生成JSON響應

const path = request.path.replace(/\/$/g, '');
const route = routeMap[request.path];
if (!route) {
  throw new HttpException(
    '沒有找到當前url對應的路由',
    HttpStatus.NOT_FOUND,
  );
}

// 異常將會被Nest自動處理,生成下面JSON響應
{
  "statusCode": 404,
  "message": "沒有找到當前url對應的路由"
}

Nest 帶有一個內建的異常層,負責處理應用程式中所有未處理的異常。當您的應用程式程式碼未處理異常時,該層將捕獲該異常,然後自動傳送適當的使用者友好響應。

開箱即用,此操作由內建的全域性異常過濾器執行,該過濾器處理型別HttpException(及其子類)的異常。當異常無法識別(既不是HttpException也不是繼承自的類HttpException)時,內建異常過濾器會生成以下預設 JSON 響應:

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

Nest自動包裝請求處理程式返回

可以看到上面請求處理程式直接返回html字串,頁面請求得到200狀態碼,text/html型別的響應體,這是怎麼回事呢?其實Nest 使用兩種不同的選項來操作響應的概念:

標準(推薦)使用此內建方法,當請求處理程式返回 JavaScript 物件或陣列時,它會自動序列化為 JSON。然而,當它返回一個 JavaScript 原始型別(例如 , string, )時,Nest 將只傳送該值而不嘗試對其進行序列化。這使得響應處理變得簡單:只需返回值,其餘的由 Nest 處理。此外,響應的狀態碼預設始終為 200,除了使用 201POST 請求。我們可以通過在處理程式級別新增裝飾器來輕鬆更改此行為(請參閱狀態碼)。number boolean @HttpCode(...)
特定於庫我們可以使用庫特定的(例如,Express響應物件,它可以使用@Res()方法處理程式簽名中的裝飾器(例如,findAll(@Res() response))注入。通過這種方法,您可以使用該物件公開的本機響應處理方法。例如,使用 Express,您可以使用類似response.status(200).send().

service層獲取靜態頁面

控制層接收到頁面請求,呼叫服務層方法獲取靜態頁面

// app.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import config from 'config';
import { IncomingHttpHeaders } from 'http';
import rp from 'request-promise';

interface CacheItem {
  etag: string;
  html: string;
}

interface HttpError<E> extends Error {
  result?: E;
}

interface HttpClientRes<T, E> {
  err?: HttpError<E>;
  statusCode?: number;
  result?: T;
  headers?: IncomingHttpHeaders;
}

@Injectable()
export class AppService {
  // 快取
  private cache: { [url: string]: CacheItem | undefined } = {};

  async fetchIndexHtml(cdnRoot: string): Promise<string> {
    // 源站靜態頁面儲存路徑
    const ossUrl = `${config.get('OSS_BASE_URL')}${cdnRoot}/index.html`;
    const cacheItem = this.cache[ossUrl];
    // 請求options
    const options = {
      uri: ossUrl,
      resolveWithFullResponse: true, // 設定獲取完整的響應,當值為false時,響應體只有body,拿不到響應體中的headers
      headers: {
        'If-None-Match': cacheItem && cacheItem.etag,
      },
    };

    // 響應
    const httpRes: HttpClientRes<any, any> = {};

    try {
      const response = await rp(options).promise();
      const { statusCode, headers, body } = response;
      httpRes.statusCode = statusCode;
      httpRes.headers = headers;
      if (statusCode < 300) {
        httpRes.result = body;
      } else {
        const err: HttpError<any> = new Error(
          `Request: 請求失敗,${response.statusCode}`,
        );
        err.name = 'StatusCodeError';
        err.result = body;
        httpRes.err = err;
      }
    } catch (err) {
      httpRes.statusCode = err.statusCode; // 對於 GET 和 HEAD 方法來說,當驗證失敗的時候(有相同的Etag),伺服器端必須返回響應碼 304 (Not Modified,未改變)
      httpRes.err = err;
    }
    if (httpRes.statusCode === HttpStatus.OK) {
      // 檔案有變化,更新快取,並返回最新檔案
      const finalHtml = this.htmlPostProcess(httpRes.result);
      const etag = httpRes.headers.etag;
      this.cache[ossUrl] = {
        etag,
        html: finalHtml,
      };
      return finalHtml;
    } else if (httpRes.statusCode === HttpStatus.NOT_MODIFIED) {
      // 檔案沒有變化,返回快取檔案
      return this.cache[ossUrl].html;
    } else {
      if (!this.cache[ossUrl]) {
        throw new HttpException(
          `不能正常獲取頁面 ${cdnRoot}`,
          HttpStatus.NOT_FOUND,
        );
      }
    }
    // 兜底
    return this.cache[ossUrl].html;
  }

  // 預處理
  htmlPostProcess(html: string) {
    return html;
  }
}

服務層請求靜態資源

上面使用request-promise包傳送服務端ajax請求,它依賴於request,兩個包都需要安裝:

yarn add request-promise request

請求index.html後服務端會進行快取,這麼做主要有兩點:

  • 對於 GETHEAD 方法來說,當驗證失敗的時候(有相同的Etag),伺服器(源站)端必須返回響應碼 304 (Not Modified,未改變),不會返回html字串響應體。如果路由服務不做快取,就只能給瀏覽器返回404;如果路由服務快取了,就可以給瀏覽器返回快取中的index.html,避免使用者不能使用。
  • 假如源站崩了,我們還有路由服務的快取作兜底,在一定程度上能減小對使用者使用的影響。

靜態資源預處理

現在回到我們搭建前端路由服務最核心的部分,通過前端路由服務獲取源站上部署的單頁面應用的index.html後,我們可以根據實際需求對其進行預處理,然後再將其返回給瀏覽器。

比如,我們在開篇說到統一對所有單頁面專案引入神策埋點Web JS SDK

// 預處理
htmlPostProcess(html: string) {
  const $ = cheerio.load(html);
  $('head').append(
    `<script type="text/javascript" src="https://mstatic.cassmall.com/assets/sensors/cassSensors.js"></script>`,
  );
  return $.html();
}

另外,單頁面應用構建後,生成的cssjs一般會以相對引用路徑注入到index.html中,形如:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <link href="./static/css/22.64074466.chunk.css" rel="stylesheet"/>
  <script src="./static/js/22.66c89a53.chunk.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

瀏覽器訪問https://ec-alpha.casstime.com/mall/inquiry#/xxx,前端路由服務拉取源站www/cassmall/inquiry/目錄下index.html返回給瀏覽器,瀏覽器解析index.html發現有linkscript引用,就會去請求對應的cssjs靜態資源,而linkscript引用路徑都是相對路徑,相對瀏覽器訪問的路徑,實際請求的路徑為https://ec-alpha.casstime.com/mall/inquiry/static/css/22.64074466.chunk.csshttps://ec-alpha.casstime.com/mall/inquiry/static/js/22.66c89a53.chunk.js,這個請求路徑是錯誤的(404),cssjs靜態資源應該向CDN獲取,CDN上沒有再向源站獲取,因此需要再預處理函式中將相對路徑改成從CDN獲取的絕對路徑

<!-- 預處理後 -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <!-- <link href="./static/css/22.64074466.chunk.css" rel="stylesheet"/> -->
  <link href="https://mstatic.cassmall.com/www/cassmall/inquiry/static/css/22.64074466.chunk.css" rel="stylesheet"/>
  <!-- <script src="./static/js/22.66c89a53.chunk.js"></script> -->
  <script src="https://mstatic.cassmall.com/www/cassmall/inquiry/static/js/22.66c89a53.chunk.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
// app.utils.ts
export function joinUrlPath(baseUrl: string, ...paths: string[]): string {
  const urlObj = url.parse(baseUrl);
  urlObj.pathname = path.posix.join(urlObj.pathname, ...paths);
  const searchIdx = urlObj.pathname.indexOf('?');
  if (searchIdx > -1) {
    urlObj.search = urlObj.pathname.slice(searchIdx + 1);
    urlObj.pathname = urlObj.pathname.slice(0, searchIdx);
  }
  return url.format(urlObj);
}

export function isFullUrl(url: string) {
  return /^https?:|^\/\//.test(url);
}

// 預處理
htmlPostProcess(html: string, cdnRoot: string) {
  const $ = cheerio.load(html);
  const cdn = 'https://mstatic.cassmall.com'; // CDN域名
  const baseUrl = joinUrlPath(cdn, cdnRoot, '/'); // 拼接路徑
  
  // 替換link相對路徑引用
  $('link').each((index: number, ele: cheerio.TagElement) => {
    let href = ele.attribs['href'];
    if (href && !isFullUrl(href)) {
      href = joinUrlPath(baseUrl, href);
      ele.attribs['href'] = href;
    }
  });
  // 替換script相對路徑引用
  $('script').each((index: number, ele: cheerio.TagElement) => {
    let src = ele.attribs['src'];
    if (src && !isFullUrl(src)) {
      src = joinUrlPath(baseUrl, src);
      ele.attribs['src'] = src;
    }
  });
  
  // 新增神策埋點Web JS SDK
  $('head').append(
    `<script type="text/javascript" src="https://mstatic.cassmall.com/assets/sensors/cassSensors.js"></script>`,
  );
  
  return $.html();
}

自定義日誌中介軟體

日誌系統需要監聽所有路由請求情況,應該設定為全域性中介軟體,使用由INestApplication 提供的use()方法來定義全域性中介軟體

執行下面命令生成中介軟體檔案:

/** generate|g [options] <schematic> [name] [path] */
nest g mi logger middleware

目錄結構

src
├── middleware
        ├── logger.middleware.spec.ts
        ├── logger.middleware.ts
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts

bunyanNode.js的一個簡捷高效的JSON日誌庫,這裡使用其搭建日誌系統。建立src/utils/logger.ts檔案,個性化配置初始化日誌例項:

// src/utils/logger.ts
import Logger, { LogLevelString } from 'bunyan';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require(`${process.cwd()}/package.json`);
const level = (process.env.LOG_LEVEL || 'info').toLowerCase() as LogLevelString;

const logger = Logger.createLogger({
  name: pkg.name,
  level,
});

export default logger;

然後,在中介軟體引入日誌例項幫助列印日誌:

// logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    next();
    logger.info(
      {
        type: 'access',
        method: req.method,
        url: req.url,
        userAgent: req.headers['user-agent'],
        statusCode: res.statusCode,
      },
      `%s %s %d`,
      req.method,
      req.url,
      res.statusCode,
    );
  }
}

註冊中介軟體

// src/main.ts
app.use(new LoggerMiddleware().use);

修改啟動命令:

"scripts": {
    "start": "nest start“, // 改動前
    "start": "nest start | bunyan -L" // 改動後
}

日誌列印效果:

image-20220214201842972.png

專案地址:https://github.com/Revelation...

相關文章