可视化编辑器(postman预请求脚本)

419 阅读7分钟

前言

最近收到一个需求挺有意思的,那就是给可视化编辑器的动态请求配置面板添加pre-request请求脚本预处理。跟postman的pre-request功能一样,在发送请求之前先执行脚本给编辑器设置变量,然后在params|Body|Header面板通过{{variable}}使用变量。
接下来就来讲解一下此功能如何实现的,其本质就是在web端执行脚本注入变量因为在JavaScript执行第三方代码,因此实现一个JS沙箱来进行执行环境隔离是非常重要的,不能因为执行别人的脚本异常影响到编辑器的能力。这里不会涉及到CSS 隔离只是纯JS 隔离,针对CSS隔离计划在我的编辑器插件设计完成之后会进行分享image.png

需求分析

在正式开始功能实现之前,我们需要先充分理解需求。

postman如何使用环境变量和预处理脚本

  1. postman分为Globals(全局变量) < Environments (环境变量) < Collections (集合变量) < variables(局部变量, 层越深优先级越高,遇到同名key内层覆盖外层 image.png
  2. 首先配置环境变量的属性名和变量值 image.png 在接口里配置params|Headers|Body...。[Key]:{{varible}}的形式 image.png 当postman在环境变量中能找到{{varible}}的声明则替换成对应的值,没有找到保持原有 image.png
  3. 此时你会发现针对于AppKeySignature加密key是通过CryptoJS脚本执行加密得来的,单纯的通过配置环境变量并不能满足我们的需求。此时pre-request就发挥了他的作用。

script中的pre-script编写JavaScript脚本语法生成对应变量的值,然后通过pm.global.set(key,value)赋值全局变量pm.environment.set(key,value)赋值环境变量pm.variables.set(key,value)赋值局部变量。如下我赋值了局部变量 image.png 如下可以看到,在该请求就能获取到在pre-script设置的对应变量了 image.png

功能实现

整体项目结果

创建一个PM顶级类管理旗下的Global类Environment类...环境变量数据对象保存在当前类实例下面,这么设计的好处在于方便后续功能拓展。创建一个CodeSandbox类实现JS沙箱用于执行第三方脚本数据 image.png

这里模拟了三个组件(组件代表可视化里配置动态数据的组件), 因为是demo所以比较简陋对于 输入区域可以采用Monaco或者codemirror实现语法高亮代码补全格式化代码格式。 如果不想集成编辑器,那么可以采用highlightprettier进行高亮和格式化

  • 第一个组件除了设置变量还会手动抛出异常
  • 第三个组件模拟使用pm内置库CryptoJS进行数据加密 image.png

环境变量相关类

PM类声明和定义

  1. variables是每个组件私有的因此使用variablesMap使用组件id缓存对应的variable实例从而建立了组件作用域变量
  2. 通过setVariableId聚焦当前选中的组件就可以通过设置get variables方法从而实现pm.variables获取当前聚焦组件的私有变量
  3. Request代表当前组件的request对象这里不做缓存,因此聚焦下一个组件就把前一个组件的request对象直接移除掉缓存当前组件的request对象。每次都重新new一个新的对象的好处在于足够干净不会存在遗留未重置的属性
  4. CodeSandbox代表的沙箱管理类型
import Variables from './Variables.js'
import Global from './Global.js'
import Environment from './Environment.js'
import CodeSandbox from './CodeSandbox.js'
import Request from './Request.js'
import Response from './Response.js'

export default class PM {
    // 全局变量
    global = null
    environment = null
    // 局部变量
    variablesMap = null
    // 当前聚焦局部变量
    variableId = null
    // 沙箱缓存
    codeSandboxMap = null
    // request缓存
    requestMap = null
    // Response 缓存
    responseMap = null

    constructor() {
        this.global = new Global()
        this.environment = new Environment()
        this.variablesMap = new Map()
        this.codeSandboxMap = new Map()
        this.requestMap = new Map()
        this.responseMap = new Map()
    }

    sleep(time = 100) {
        return new Promise(resolve => {
            setTimeout(resolve, time)
        })
    }

    async execScriptInSandbox(id, scriptText) {
        // 获取沙箱实例且执行
        const codeSandbox = this.codeSandboxMap.get(id)
        codeSandbox.execScript(scriptText)
        // !!目前只支持同步任务
        // TODO: 目前没有去判断沙箱内部代码是否完成,所以这里暂时先sleep一下,等待沙箱执行完毕 
        // 后续追加代码分析,判断沙箱是否执行完毕
        await this.sleep(100)
    }

    setRequest(id, req) {
        if (!this.requestMap.has(id)) {
            const request = new Request(req)
            this.requestMap.set(id, request)
        }
    }

    setResponse(id, res) {
        if (!this.responseMap.has(id)) {
            const response = new Response(res)
            this.responseMap.set(id, response)
        }
    }

    getResponseBody(id) {
        return this.responseMap.get(id).json()
    }

    getVariables(id) {
        return this.variablesMap.get(id).toObject()
    }

    setVariableId(id) {
        // 每个组件都有自己的环境,这样子也不存在并发问题

        //  $ 套件无需特殊处理
        if (!this.variablesMap.has(id)) {
            // console.log('当前组件变量作用域已存在,无需重新创建');
            const variables = new Variables(this)
            this.variablesMap.set(id, variables)
        }

        if (!this.codeSandboxMap.has(id)) {
            // 创建干净的沙箱实例(每一个组件都有独立的沙箱环境) 
            const codeSandbox = new CodeSandbox(id)
            this.codeSandboxMap.set(id, codeSandbox)
        }

    }

    // 解锁
    unSetVariableId(id) {
        // 销毁沙箱实例
        const codeSandbox = this.codeSandboxMap.get(id)
        codeSandbox?.destroy()
        this.codeSandboxMap.delete(id)
        this.variablesMap.delete(id)
        this.requestMap.delete(id)
        this.responseMap.delete(id)
    }

}

Variables类声明和定义,Global类Environment类类似

  1. 声明一个data存储设置的变量数据
export default class Variables {
    data = new Map()
    pm = null
    
    constructor(pm) {
        this.pm = pm
    }

    has(key) {
        return this.data.has(key)
    }

    get(key) {
        return this.data.get(key)
    }

    set(key, value) {
        return this.data.set(key, value)
    }

    unset(key) {
        return this.data.delete(key)
    }

    clear() {
        this.data.clear()
    }

    toObject() {
        const global = this.pm.global.toObject()
        const environment = this.pm.environment.toObject()
        const variables = Object.fromEntries(this.data)
        return { ...global, ...environment, ...variables }
    }
}

CodeSandbox沙箱声明和定义

  1. 通过iframe+proxy+with实现JS沙箱
  2. 首先创建一个iframe,其次通过proxy拦截iframe的window对象(this.iframeWindow)
  3. 针对于pmCryptoJS对象开放白名单,让沙箱内部可以访问父级的window对象
  4. 组件的每一次执行都会创建一个新的沙箱环境,确保组件与组件之间或者单个组件多次执行不会相互影响都有自己的独立环境
import CryptoES from 'crypto-es'

class CodeSandbox {
    // iframe 实例
    iframe = null
    // iframe 的 Window 实例
    iframeWindow = null
    // 环境变量id
    variableId = null
    constructor(id) {
        // 当前沙箱所属的环境变量id
        this.variableId = id
        window.CryptoJS = CryptoES
        this.iframe = this.createIframe()
        this.iframeWindow = this.iframe.contentWindow
        this.proxyIframe()
    }

    createIframe() {
        const iframe = document.createElement('iframe')
        iframe.setAttribute('src', 'about:blank')
        iframe.setAttribute('style', 'display: block;width:0; height:0;border:none;')
        document.body.appendChild(iframe)
        return iframe
    }

    proxyIframe() {
        this.iframeWindow.proxy = new Proxy(this.iframeWindow, {
            get: (target, prop) => {
                // 支持postman外置库
                // https://postman.xiniushu.com/docs/writing-scripts/script-references/postman-sandbox-api-reference
                if (prop === 'pm') {
                    /**
                     * 1. pm从全局变量获取
                     * 2. variables和request (不共用额外处理)
                     * 3. 其他pm属性,从pm下获取
                     */
                    return new Proxy(window[prop] || {}, {
                        get: (subTarget, subProp) => {
                            if (subProp === 'variables') {
                                return subTarget.variablesMap.get(this.variableId)
                            } else if (subProp === 'request') {
                                return subTarget.requestMap.get(this.variableId)
                            } else if (subProp === 'response') {
                                return subTarget.responseMap.get(this.variableId)
                            }
                            return subTarget[subProp]
                        },
                    })
                }

                if (prop === 'CryptoJS') {
                    return window[prop]
                }

                if (prop === 'window' || prop === 'self') {
                    return this.iframeWindow.proxy
                }
                // TODO: 留给有缘人,一些错误边界问题,遇到再处理,it is no time fix it
                return target[prop]
            },

            set: (target, prop, value) => {
                target[prop] = value
                return true
            },

            has: (target, prop) => true,
        })
    }

    removeScript() {
        const headDom = this.iframeWindow.document.head
        while (headDom.firstChild) {
            headDom.removeChild(headDom.lastChild)
        }
    }

    execScript(scriptText) {
        const scriptElement = this.iframeWindow.document.createElement('script')
        scriptElement.textContent = `
            (function(window) {
              with(window) {
                ${scriptText}
              }
            }).bind(window.proxy)(window.proxy);
            `
        this.iframeWindow.document.head.appendChild(scriptElement)
    }

    destroy() {
        if (this.iframe) {
            this.iframe.parentNode?.removeChild(this.iframe)
        }
        this.iframe = null
        this.iframeWindow = null
    }
}

export default CodeSandbox

对一些全局属性进行白名单配置。例如postman外置库,在项目安装ESM或者UMD第三包,然后挂载到应用的window下。此沙箱还有瑕疵后续优化 image.png

预请求执行


// !!! 为什么不能使用yid作为执行脚本的唯一标识呢?
// !!! 原因: 因为请求是异步的,在轮训中可能同一个组件这次的请求还未完成,就会去执行下一次组件的请求。这次请求完成之后我会清空相关请求以及沙箱数据
// !!! 导致下一次请求时去获取 “相关请求以及沙箱数据” 会出现找不到的情况。(因为同一个组件yid是一致的)
// 也可以通过优化轮训方法来避免上一次请求没有完成,就进入到下一次请求的情况

import PM from './script/PM.js'
const pm = new PM();
window.pm = pm

const oneDom = document.getElementById('component_one_btn')
const oneInput = document.getElementById('component_one_input')
const oneOutput = document.getElementById('component_one_output')
oneDom.addEventListener('click', async () => {
    const inputText = oneInput.value
    const random = Math.random().toString(16).slice(2);
    const yid = 'one';
    const id = `${random}-${yid}`
    pm.setVariableId(id)
    await pm.execScriptInSandbox(id, inputText)
    const data = pm.getVariables(id)
    oneOutput.value = JSON.stringify(data)
    pm.unSetVariableId(id)
})

const twoDom = document.getElementById('component_two_btn')
const twoInput = document.getElementById('component_two_input')
const twoOutput = document.getElementById('component_two_output')
twoDom.addEventListener('click', async () => {
    const inputText = twoInput.value
    const random = Math.random().toString(16).slice(2);
    const yid = 'two';
    const id = `${random}-${yid}`
    pm.setVariableId(id)
    await pm.execScriptInSandbox(id, inputText)
    const data = pm.getVariables(id)
    twoOutput.value = JSON.stringify(data)
    pm.unSetVariableId(id)
})

const threeDom = document.getElementById('component_three_btn')
const threeInput = document.getElementById('component_three_input')
const threeOutput = document.getElementById('component_three_output')
threeDom.addEventListener('click', async () => {
    const inputText = threeInput.value
    const random = Math.random().toString(16).slice(2);
    const yid = 'three';
    const id = `${random}-${yid}`
    pm.setVariableId(id)
    await pm.execScriptInSandbox(id, inputText)
    const data = pm.getVariables(id)
    threeOutput.value = JSON.stringify(data)
    pm.unSetVariableId(id)
})

根据"预请求执行(pre request)"流程执行原理也支持"响应后脚本(post response)"。我们可以通过pm.response.json()来获取接口返回的数据,然后将二次处理数据,在通过pm.response.setJson更新数据 lQLPJwqAUGstkKnNAg_NA8ew7rvD7eX2d68HNwIirekUAA_967_527.png 针对在编辑器我写了hooks调用方式usePreScript有需要的同学可以参考一下。

  1. execPreRequest请求预处理,在发起请求前需要调用
  2. execPostResponse响应后处理,在请求响应之后调用
  3. destroyScript执行完请求-响应全流程,销毁沙箱和相关缓存数据。 确保每一次都是最新最干净的沙箱环境

经过上面步骤,我们就实现了如下流程: image.png

TODO 异步脚本

如何知道javascript执行栈任务已经完成?
查了一圈资料,没有找到相关有用的信息。说一下我之后实现的思路,对整个脚本进行babel解析,在脚本执行前初始化一个async为0,遇到then、settimeout等异步函数,async+1,每一个异步函数执行完async-1,当async为0时,postmessage通知上层已经执行完成

1. 声明一个全局变量 asyncAcount 记录移除执行的次数
2. 通过babel分析脚本代码,例如找到promise、settimeout... 那么 asyncAcount + 1
3. 在promise、settimeout..插入代码,当该方法执行完成就asyncAcount - 1
4. 当asyncAcount为0时,表示所有代码执行完成,通过window.postmessage通知上层已经执行完成了
5. 报错也被认定为完成
6. 对于PM类,需要创建队列排队执行

image.png

总结

通过上述步骤我们学会了,如何通过类思维让功能模块化,如何在web端安全的执行JavaScript脚本,这里的沙箱存在瑕疵后续会出专门一篇沙箱的文章。希望这篇文章对你有所帮助,到此就结束啦,此次涉及到的相关代码,点击这里

TIPS: 最新改动,每个组件都有独立的沙箱,不在共享单个沙箱。具体看代码逻辑,整体思路没有变化

如果还不会用postman的变量和预处理脚本,请阅读如下官方文章

  1. variables
  2. write scripts