背景

通常,为了更好地管理和维护项目,项目一般都会以业务范畴进行拆分,比如商品、订单、会员等等,从而产生业务职责不同的众多前端工程(SPA,单页面应用)。假设现在有个需求,所有的前端工程都需要接入神策埋点Web JS SDK,如果采用每个前端工程静态页面index.html各自引入Web JS SDK的方案,那么每个工程引入之后都需要重新部署一遍,并且以后需要更换第三方埋点SDK时,前面步骤需要重新来一遍,相当麻烦。而如果在访问所有前端工程前面加一个路由转发层,有点像前端网关,拦截响应,统一引入Web JS SDK,那么就免去了很多简单又容易出错的工作量。

前端路由服务

浏览器访问单页面应用其实就是获取应用的index.html文件解析渲染,网关会将请求转发给前端路由服务(这里前端路由服务是node服务,使用k8s注册和发现),前端路由服务根据请求路由向源站(类似阿里云对象存储服务OSS,前端项目部署上面)获取对应的index.html

浏览器加载解析html过程中,遇到linkscipt标签时就会向CDN请求cssjs静态资源,CDN上没有才去回源

现在你可能产生一个疑问,为什么html文件不是向CDN获取,而直接向源站获取,不更慢了吗?别着急,后文会细细说来

七牛云模拟实际项目部署

前端项目都会部署到对象存储服务中,比如阿里云对象存储服务OSS,华为云对象存储服务OBS,这儿我使用七牛云对象存储服务模拟实际的部署环境

一、创建存储空间,创建三级静态资源目录www/cassmall/inquiry,然后上传一个index.html模拟实际项目部署

二、给存储空间配置源站域名和CDN域名(实际配置需要先给域名备案),请求index.html使用源站域名,请求jscssimg等静态资源使用CDN域名

这里解释一下为什么到源站获取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  // 创建项目

执行完创建项目, 会初始化下面这些文件, 并且询问你要是有什么方式来管理依赖包:

如果你有安装yarn,可以选择yarn,能更快一些,npm在国内安装速度会慢一些

接下来按照提示运行项目:

项目结构

进入项目,看到的目录结构应该是这样的:

这里简单说明一下这些核心文件:

src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
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'引入,不然运行时会报下面错误:

因为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 使用两种不同的选项来操作响应的概念:

特定于库我们可以使用库特定的(例如,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" // 改动后
}

日志打印效果:

项目地址:https://github.com/Revelation...

03-05 21:40