一次基于 Service Worker 前端前置网关的实践

331 阅读5分钟

需求

近期公司出于对软件系统/平台安全上的考虑,想把名下各自的业务系统收敛到公司统一平台中作为平台子应用进行访问。各自系统日后也仅允许通过统一平台中单点登录访问,各自域名不再对外开放。不同的业务系统在统一平台中通过同一域名不同的子路径,Nginx反向代理到各自系统域名内线进行访问。鉴于业务系统的复杂性,前端项目中资源不同路径访问(asset/css/js/api)以及各子系统与统一平台权限的权限储存和访问,需要有一个前端前置网关拦截统一平台下所有资源访问和API请求,一是对子应用的资源或API访问路径正确转换,二是对子应用的资源或API访问鉴权控制。


简介(Service Worker)

W3C组织早在2014年5月就提出过 Service Worker 这样的一个HTML5 API,主要用来拦截请求或资源离线缓存(PWA)。它是浏览器的一个高级特性,本质是注册在指定源和路径下的事件驱动Web Worker。它可以充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。因为有这个特性,那么基于业务上的需求,所以构思了前端前置网关的方案 (Front-Gateway)


兼容性(Service Worker

作为一名前端开发人员使用一个不常用或比较新的特性,检查浏览器的兼容性是非常必要的。就目前看除了IE,其他游览器厂商都已经支持。

image.png


网关示例流程 (下载Demo

flow.jpg


Front-Gateway 使用

  • a. 拷贝如下文件,添加到项目根目录中

    • net-gateway-caller.js
    • net-gateway-config.js
    • net-gateway-window.js
    • net-gateway-worker.js
  • b. 在主应用 index.html 中引用 callerwindow

      <!doctype html>
        <html lang="en">
          <head>
            <meta charset="UTF-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <link rel="stylesheet" type="text/css" href="/index.css" />
            <link rel="icon" href="/logo.svg" />
            <title>Net Gateway 主应用</title>
          </head>
    
          <body>
            <div id="app"></div>
          </body>
    
          <!-- 配置如下 -->
          <script type="module" src="/net-gateway-caller.js"></script>
          <script type="module" src="/net-gateway-window.js"></script>
        </html>
    

  • c. 根据需求,修改配置文件 net-gateway-config.js

        // @ts-nocheck
        /* eslint-disable */
    
        /**
         * 配置需要拦截的请求
         */
        const NetProxys = [
          /^http:\/\/localhost:5179(\/[\s\S]*)?$/i
        ]
    
    
        /**
         * 配置无需拦截的请求 - (过滤 NetProxys 选项)
         */
        const NetWhites = [
          /^http:\/\/localhost:5179\/net-gateway-config\.js(\?[\s\S]+)?/i,
          /^http:\/\/localhost:5179\/net-gateway-caller\.js(\?[\s\S]+)?/i,
          /^http:\/\/localhost:5179\/net-gateway-worker\.js(\?[\s\S]+)?/i,
          /^http:\/\/localhost:5179\/net-gateway-window\.js(\?[\s\S]+)?/i,
          /^http:\/\/localhost:5179\/favicon\.ico(\?[\s\S]+)?/i
        ]
    
    
        /**
         * 配置需要注入的脚本 - (仅在HTML页面导航访问,且NetPing开启时有效)
         */
        const NetScripts = [
          '<script type="text/javascript" src="/net-gateway-caller.js"><\/script>',
          '<script type="text/javascript" src="/net-gateway-window.js"><\/script>'
        ]
    
    
        /**
         * 配置子应用的请求
         * 
         *   eg. 子应用 http://localhost:5179/_app_/child1/index.html 请求,则相当于 child1 应用的 base 为 /_app_/child1
         *       那么在 child1 应用发起 http://localhost:5179/ant.png 请求, 会转换成 http://localhost:5179/_app_/child1/ant.png 请求
         *       
         *   eg. 子应用 http://localhost:5179/_app_/child2/index.html 请求,则相当于 child2 应用的 base 为 /_app_/child2
         *       那么在 child2 应用发起 http://localhost:5179/home.png 请求, 会转换成 http://localhost:5179/_app_/child2/home.png 请求
         * 
         */
        const NetSubRoute = /^http:\/\/localhost:5179(\/_app_\/[^\/]+\/)[\s\S]*/i
        const NetSubRewirte = /^(http:\/\/localhost:5179)\//i
    
    
        /**
         * 重定向处理 follow | error | manual | default
         */
        const NetRedirect = 'default'
    
    
        /**
         * 是否开启网关
         */
        const NetGateway = true
    
    
        /**
         * 是否开启日志
         */
        const NetDebug = false
    
    
        /**
         * 是否开启Ping
         */
        const NetPing = true
    

  • d. 根据业务逻辑调用 API

      // clearCerter
      NetCaller().clearCerter({})
    
      // resetCerter
      NetCaller().resetCerter({ 'jwt-key': 'jwt-secret' })
    
      // updateCerter
      NetCaller().updateCerter({ 'jwt-key': 'jwt-secret' })
    
      // removeCerter
      NetCaller().removeCerter({ 'jwt-key': 'jwt-secret' })
    
    

那相比于 Cookie 呢?

看到这里你可能会想到,如果主应用和子应用的资源访问符合浏览器的同源策略是否可以使用Cookie来实现前置网关呢?答案是可以的,但是对于Service Worker可以拦截请求/处理响应等功能是Cookie所不具备的。对比如下:

  • 由于安全策略限制,Service Worker 仅支持 localhost/127.0.0.1 和 https 协议的 Web 服务,但是Cookie 却没有这方面的限制。也就是说如果主应用是一个超级App,而子应用是App里H5应用,那么 Service Worker 受限于App不是浏览器而无法起效,Cooike 则可以。

  • Cookie 只能作为前端与服务端之间彼此传递网关安全凭证的桥梁,无法对前端发起的请求做拦截,也无法处理处理服务端返回的响应数据。而 Service Worker 则可以拦截浏览器本站点所有资源及API请求,且可以重构新的Request发起请求,并拦截返回响应根据条件执行不同的处理(重定向、正常返回,读取缓存返回等)

  • Cookie 与 Service Worker 有着完全不同的生命周期。Cookie 生命周期取决于 Expires 设定及用户在浏览器的管理。Service Worker 生命周期取决于它所控制的那些客户端(浏览器标签页)是否存活着,只要客户端与 Service Worker 服务保持连接就一直存活,如果所有与它连接的客户端都被关掉,那么 Service Worker 进行Stop阶段,稍后就被销毁。


Service Worker 相关应用

  • 安装 installl
    /**
     * self.skipWaiting() ---- 强制正在等待的servicework工作线程进入激活状态
     * event.waitUntil() ----- 它可以接受Promise, 可以确保直到Promise被resolved,才进入下个阶段
     */
    self.addEventListener('install', event => {
      event.waitUntil(self.skipWaiting())
    })
  • 激活 activate
    /**
     * self.clients.claim() -- 控制未受控制的客户端,会触发其客户端 oncontrollerchange 事件
     *                      -- 即 window.navigator.serviceWorker.oncontrollerchange
     *
     * event.waitUntil() ----- 它可以接受Promise, 可以确保直到Promise被resolved,才进入下个阶段
     */
    self.addEventListener('activate', event => {
      event.waitUntil(self.clients.claim())
    })
  • 通讯 message
    /**
     * message --- 客户端 与 ServiceWorker之间通过 postMessage 和 onmessage 进行通讯
     *         --- 建议每个客户端 与 ServiceWorker 建立专用的 Channel, 而不是用公用的消息通道传递 
     */
    self.addEventListener('message', event => {
      const id = event.source.id
      const uid = event.data.uid
      const type = event.data.type
      const port = event.ports && event.ports[0]
      
      // ...
    })
  • 通讯 message
    /**
     * fetch --- 拦截事件, 拦截请求,重构请求、拦截响应、处理响应
     */
    self.addEventListener('fetch', event => {
      const promise = new Promise((resolve, reject) => {
        // ...
      })
      
      event.respondWith(promise)
    })