浅谈滴滴Dokit业务代码零侵入思想(小程序端)

1,243 阅读8分钟

本文要点:

  • 了解Dokit小程序端业务代码零侵入的思想
  • 了解关于位置模拟、请求注射、APImock功能中业务代码零侵入的具体实现

一、前言

1.1 Dokit组件功能的简要分类

在之前的前端初学者读滴滴Dokit小程序源代码系列文章中,我们介绍了微信小程序端的基本语法、特色功能如事件绑定、条件渲染、列表渲染、事件通信等内容。我们也简要的分析了Dokit的两个组件:index组件与debug组件。到目前为止我们已经基本掌握了分析Dokit小程序端源码的基本知识,之后只要按着类似的分析方法,结合响应的微信小程序API与JavaScript语法即可逐个解读,了解各个功能具体的实现方式。因此,这次我们跳出具体的某个组件。
宏观的看Dokit的组件功能,可以分为两种:

  1. 组件自身对业务代码的输出不产生或很少产生影响,其目的为快速查看某些信息,代表组件为App信息、缓存管理、H5任意门等。
  2. 组件自身对业务代码的输出产生了影响,其目的为测试业务代码的输出结果/模拟用户的输入,代表组件为位置模拟、请求注射、APImock等。
    在微信小程序端的Dokit组件中第一类组件主要是通过对系统接口函数的封装来实现的,而第二类组件便是基于业务代码零侵入的思想,通过改写系统接口函数来实现的。
    本文就来浅谈一下关于Dokit业务代码零侵入的思想。

1.2 什么是业务代码零侵入

我们先来看一个简单的应用场景:假设我的APP有一个与位置有关的功能,代码如下:

wx.getLocation({
    type: 'gcj02',
    success (res) {
      const latitude = res.latitude
      const longitude = res.longitude
      ...
      //业务逻辑
      ...
    }
})

现在我想测试当地理位置为上海某个具体位置时的该功能输出结果,如果我通过修改代码来实现:

Dump.getLocation(ShanghaiPos,{
    type: 'gcj02',
    success (res) {
      const latitude = res.latitude
      const longitude = res.longitude
      ...
      //业务逻辑
      ...
    }
})

那么这样显而易见的会有一个测试问题:每次测试后都要重新修改源代码,十分麻烦而且也不利于调试,每次只模拟一个情况就要重新修改代码,重新编译,效率十分低下。
这种测试方法显然不是我们所希望的,我们希望的是在不修改源代码的前提下测试源代码,而Dokit中的位置模拟模块就满足了这种需求:我们只需要打开该组件,选择好自己想模拟的位置,即可进行测试,无需修改我们自己的业务代码,大幅度提高测试效率。
也就是说,我们需要一种技术方案,这种方案能够使开发人员不修改自己的源代码即可进行相应的测试,这种思想就被称为“业务代码零侵入”。

二、技术实现

2.1 技术核心:Object.defineProperty

如果用两个字来描述的话,那就是:拦截
将原生API拦截,让开发人员调用API时调用修改后的API。
Dokit小程序端实现业务代码零侵入的主体思路是利用JavaScript中的静态方法Object.defineProperty对微信提供的接口API进行相应的改写,使得测试过程中用户自己的业务代码不受到影响。
关于该函数的具体介绍可以参考相关文档 ,在Dokit的主要有以下两种使用方式:

  • 为接口API设置getter函数,当用户调用该接口时,会调用Dokit设置好的get函数,来影响业务代码的输出。
  • 设置接口API的属性描述符writable:true,使这个接口的API能被赋值运算符进行改变,再通过将该接口修改为Dokit设置的函数,使用户再调用该接口时调用该函数,来影响业务代码的输出。

defineproperty.png

2.2 位置模拟中的相关实现

位置模拟的关键就是拦截wx.getLocation接口,将该接口原先返回的实际地理位置改成需要的地理位置,具体代码如下:

choosePosition (){
    wx.chooseLocation({
        success: res => {
            this.setData({ currentLatitude: res.latitude });
            this.setData({ currentLongitude: res.longitude })
            Object.defineProperty(wx, 'getLocation', {
                get() {
                    return function (obj) {
                        obj.success({latitude: res.latitude, longitude: res.longitude})
                    }
                }
            })
        }
    })
}

可以看到,位置模拟的实现方式是先调用wx.chooseLocation接口选择好想模拟的位置,之后通过Object.defineProperty方法设置了wx.getLocation接口的get函数,将本来应该返回的实际地理位置信息对象改为想模拟的地理位置。

location2.png
还原位置的方法很简单,再次使用Object.defineProperty方法将wx.getLocation接口的get函数设定为之前挂载(保存)在app实例上的原生接口函数,这样等用户再调用该接口的时候就会调用原生接口函数,具体代码如下:

resetPosition (){
    Object.defineProperty(wx, 'getLocation',
    {
        get() {
            return app.originGetLocation
        }
    });
    wx.showToast({title:'还原成功!'})
    this.getMyPosition()
}

之后提及到的拦截接口还原方式都是类似的,将接口再设定为之前挂载在app实例上的的原生接口函数,不再赘述。

2.3 请求注射中的相关实现

请求注射的关键就是拦截wx.request接口,将接收到的数据实现进行注射修改,再传给业务代码使用,具体代码如下:

hooksRequest() {
    Object.defineProperty(wx,  "request" , { writable:  true });
    const hooksRequestSuccessCallback = this.hooksRequestSuccessCallback
    wx.request = function(options){
        const originSuccessCallback = options.success
        options.success = res => {
            originSuccessCallback(hooksRequestSuccessCallback(res))
        }
        app.originRequest(options)
    }
}

可以看到,请求注射的实现方式是通过Object.defineProperty方法将wx.requestwritable属性修改为true,之后重写该接口,将原来options对象中的success回调函数得到的正常response响应对象通过hooksRequestSuccessCallback()函数进行注射,再执行原来的网络请求。这样就可以实现业务代码接收到的response对象为注射后的对象。
hooksRequestSuccessCallback()函数的用途是根据用户填入Dokit中的注射列表来进行相应的key-value键值对的属性修改,详细的逻辑可以参考源代码。

inject.png

2.4 APImock中的相关实现

与请求注射相同,APImock的关键也是拦截wx.request接口,若当前网络请求的网址路径在用户Dokit平台端的mock列表中,则进行接口mock:将当前请求拦截,给应用端返回一个Dokit平台端模拟的服务器响应。
APImock组件可能是Dokit小程序端中实现最复杂的一个组件,所以我们来详细分析一下APImock的实现代码,这里先上一个Dokit官方提供的逻辑流程图:

mock大流程图.png

比我自己写的流程图好多了哈
根据流程图添加注释后的代码如下:

addRequestHooks () {
   Object.defineProperty(wx,  "request" , { writable:  true });//拦截wx.request方法
   console.group('addRequestHooks success') 
   const matchUrlRequest = this.matchUrlRequest.bind(this) 
   const matchUrlTpl = this.matchUrlTpl.bind(this) 
   wx.request = function (options) { //重写接口函数
       const opt = util.deepClone(options)
       const originSuccessFn = options.success  //保存业务代码中的success回调函数
       const sceneId = matchUrlRequest(options) //判断是否满足命中规则
       if (sceneId) {
           options.url = `${mockBaseUrl}/api/app/scene/${sceneId}`
           console.group('request options', options)
           console.warn('被拦截了~')
       }
       options.success = function (res) {
                   originSuccessFn(matchUrlTpl(opt, res)) //匹配模版规则
       }
       app.originRequest(options)
   }
}

重写的wx.request接口中先做的事情就是对接口参数options进行了深拷贝,便于之后上传模版数据,之后通过matchUrlRequest()函数来判断当前网络请求是否命中拦截规则。我们接下来来看看具体的拦截规则是什么:

matchUrlRequest (options) {
    let flag = false, curMockItem, sceneId;
    if (!this.data.mockList.length) { return false }
    for (let i = 0,len = this.data.mockList.length; i < len; i++) {
        curMockItem = this.data.mockList[i]
        if (this.requestIsmatch(options, curMockItem)) {
            flag = true
            break;
        }
    }
    if (curMockItem.sceneList && curMockItem.sceneList.length) {
        for (let j=0,jLen=curMockItem.sceneList.length; j<jLen; j++) {
            const curSceneItem = curMockItem.sceneList[j]
            if (curSceneItem.checked) {
                sceneId = curSceneItem._id
                break;
            }
        }
    } else {
        sceneId = false
    }
    return flag && curMockItem.checked && sceneId
}

函数中先遍历了用户的mockList列表查找是否有匹配当前请求的mock响应,如果有匹配的响应(requestIsmatch函数返回trueflag = true),再遍历这个响应的场景列表sceneList查找用户选择的是什么场景,根据选择的场景来返回响应的sceneId
进一步深入,我们来看看requestIsmatch函数判断请求是否匹配的具体逻辑:

requestIsmatch (options, mockItem) {
    const path = util.getPartUrlByParam(options.url, 'path')
    const query = util.getPartUrlByParam(options.url, 'query')
    return this.urlMethodIsEqual(path, options.method, mockItem.path, mockItem.method) && this.requestParamsIsEqual(query, options.data, mockItem.query, mockItem.body)
}

requestIsmatch函数实际上是封装了两个测试函数:urlMethodIsEqualrequestParamsIsEqual函数,分别检测请求的路径、方法和请求参数。具体代码如下:

urlMethodIsEqual (reqPath, reqMethod, mockPath, mockMethod) {
    reqPath = reqPath ? `/${reqPath}` : ''
    reqMethod = reqMethod || 'GET'
    return (reqPath == mockPath) && (reqMethod.toUpperCase() == mockMethod.toUpperCase())
}

urlMethodIsEqual函数判断请求的路径与请求方式(GET、POST或其他)是否与设定好的mock接口一致。

requestParamsIsEqual (reqQuery, reqBody, mockQuery, mockBody) {
    reqQuery = util.search2Json(reqQuery)
    reqBody = reqBody || {}
    try {
        return (JSON.stringify(reqQuery) == mockQuery) && (JSON.stringify(reqBody) == mockBody)
    } catch (e) {
        return false
    }
}

requestParamsIsEqual函数判断请求的参数是否与设定好的mock接口一致(包括Query请求体和Body请求体)
总结一下,具体的判断是否命中拦截的流程如图:

拦截规则.png
回到addRequestHooks函数中,命中模版规则后,函数将原请求的网址url改为Dokit的相应路径${mockBaseUrl}/api/app/scene/${sceneId},进而返回mock接口的响应。
在这个过程中,Dokit还改写了options参数的success回调函数,用matchUrlTpl函数来判断收到的响应是否命中模版规则,如果命中的话就将这个响应对象变成模版保存下来。具体代码如下:

matchUrlTpl (options, res) {
    let curTplItem,that = this
    if (!that.data.tplList.length) { return res }
    for (let i=0,len=that.data.tplList.length;i<len;i++) {
        curTplItem = that.data.tplList[i]
        if (that.requestIsmatch(options, curTplItem) && curTplItem.checked && res.statusCode == 200) {
            that.data.tplList[i].templateData = res.data
        }
    }
    wx.setStorageSync('dokit-tpllist', that.data.tplList)
    return res
}

模版规则相比拦截规则要简单一些:先利用requestIsmatch函数判断当前请求是否与模版列表TplList匹配,如果匹配且响应成功(curTplItem.checked && res.statusCode == 200),就将其保存下来(wx.setStorageSync)等待用户的浏览与上传。
在改写的接口函数最后,执行原生接口函数app.originRequest。整个拦截改写接口流程结束。
在APIMock功能组件的实现中,Dokit利用Object.defineProperty方法改写request接口,不仅不需要修改业务代码中接口函数的调用,而且对url参数的重写,甚至连业务代码中请求的url参数都不需要改变,真正的实现了“业务代码零侵入”。

三、总结

本篇文章通过对Dokit小程序端三个组件位置模拟、请求注射、APImock的主体实现的相关代码阅读了解了Dokit“业务代码零侵入”的思想。
在阅读源码的过程中,我们不仅是要简单的阅读某个组件是如何实现的,也要了解Dokit的宏观设计思路,更重要的是了解这种“发现业务痛点→针对性的提出解决方案→最终技术实现”的流程。
说了这么多也只是本人的一点浅显的理解,权当抛砖引玉,如有错误或疏漏还望批评指教。