背景
通常,為了更好地管理和維護專案,專案一般都會以業務範疇進行拆分,比如商品、訂單、會員等等,從而產生業務職責不同的眾多前端工程(SPA
,單頁面應用)。假設現在有個需求,所有的前端工程都需要接入神策埋點Web JS SDK
,如果採用每個前端工程靜態頁面index.html
各自引入Web JS SDK
的方案,那麼每個工程引入之後都需要重新部署一遍,並且以後需要更換第三方埋點SDK
時,前面步驟需要重新來一遍,相當麻煩。而如果在訪問所有前端工程前面加一個路由轉發層,有點像前端閘道器,攔截響應,統一引入Web JS SDK
,那麼就免去了很多簡單又容易出錯的工作量。
前端路由服務
瀏覽器訪問單頁面應用其實就是獲取應用的index.html
檔案解析渲染,閘道器會將請求轉發給前端路由服務(這裡前端路由服務是node
服務,使用k8s
註冊和發現),前端路由服務根據請求路由向源站(類似阿里雲物件儲存服務OSS
,前端專案部署上面)獲取對應的index.html
瀏覽器載入解析html
過程中,遇到link
、scipt
標籤時就會向CDN
請求css
、js
靜態資源,CDN
上沒有才去回源
現在你可能產生一個疑問,為什麼html
檔案不是向CDN
獲取,而直接向源站獲取,不更慢了嗎?彆著急,後文會細細說來
七牛雲模擬實際專案部署
前端專案都會部署到物件儲存服務中,比如阿里雲物件儲存服務OSS
,華為雲物件儲存服務OBS
,這兒我使用七牛雲物件儲存服務模擬實際的部署環境
一、建立儲存空間,建立三級靜態資源目錄www/cassmall/inquiry
,然後上傳一個index.html
模擬實際專案部署
二、給儲存空間配置源站域名和CDN
域名(實際配置需要先給域名備案),請求index.html
使用源站域名,請求js
、css
、img
等靜態資源使用CDN
域名
這裡解釋一下為什麼到源站獲取index.html
,而不是通過CDN
域名獲取?假設通過CDN
獲取index.html
,當第一次部署單頁面應用,假設瀏覽器訪問http://localhost:3000/mall/inquiry/#/xxx
,CDN
上沒有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
字尾的檔案不做快取處理。
另外,js
、css
、img
、video
這類靜態資源我們希望頁面能夠快速載入,因此通過CDN
加速獲取。js
、css
可能改動比較頻繁,但在構建後都會根據內容生成hash
重新命名檔案,若檔案有更改,其hash
也會變化,請求時不會命中CDN
快取,會回源;若檔案沒有更改,其hash
不會變化,則會命中CDN
快取。img
、video
改動不會很頻繁,如需要改動,則重新命名上傳即可,防止同樣名稱命中CDN
快取。
Nest
搭建前端路由服務
專案建立
首先確定你已經安裝了Node.js
, Node.js
安裝會附帶npx
和一個npm
包執行程式。請確保在您的作業系統上安裝了Node.js (>= 10.13.0,v13 除外)
。要建立新的Nest.js
應用程式,請在終端上執行以下命令:
npm i -g @nestjs/cli // 全域性安裝Nest
nest new web-node-router-serve // 建立專案
執行完建立專案, 會初始化下面這些檔案, 並且詢問你要是有什麼方式來管理依賴包:
如果你有安裝yarn
,可以選擇yarn
,能更快一些,npm
在國內安裝速度會慢一些
接下來按照提示執行專案:
專案結構
進入專案,看到的目錄結構應該是這樣的:
這裡簡單說明一下這些核心檔案:
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.js
、development.js
、production.js
,新增如下配置:
default.js
配置index.html
請求路徑與真實在源站儲存路徑的對映關係development.js
、production.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
第三方模組config
是cjs
規範的模組,使用esm
方式引入cjs
之前需要在tsconfig.json
新增配置:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true, // ESM匯出沒有設定default,被引入時不報錯
"esModuleInterop": true, // 允許使用ESM帶入CJS
}
}
當然你可以直接使用cjs
規範引入const config = require('config')
或者改成import * as config from 'config'
引入,不然執行時會報下面錯誤:
因為esm
匯入 cjs
,esm
有 default
這個概念,而 cjs
沒有。導致匯入的config
值為undefined
任何匯出的變數在 cjs
看來都是 module.exports
這個物件上的屬性,esm
的 default
匯出也只是 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 ,除了使用 201 的 POST 請求。我們可以通過在處理程式級別新增裝飾器來輕鬆更改此行為(請參閱狀態碼)。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
後服務端會進行快取,這麼做主要有兩點:
- 對於
GET
和HEAD
方法來說,當驗證失敗的時候(有相同的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();
}
另外,單頁面應用構建後,生成的css
、js
一般會以相對引用路徑注入到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
發現有link
、script
引用,就會去請求對應的css
、js
靜態資源,而link
、script
引用路徑都是相對路徑,相對瀏覽器訪問的路徑,實際請求的路徑為https://ec-alpha.casstime.com/mall/inquiry/static/css/22.64074466.chunk.css
和https://ec-alpha.casstime.com/mall/inquiry/static/js/22.66c89a53.chunk.js
,這個請求路徑是錯誤的(404),css
、js
靜態資源應該向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
bunyan
是Node.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" // 改動後
}
日誌列印效果: