系统测试利器-挡板实战

【51CTO.com原创稿件】

挡板起因

  • 当今是多服务多协作的时代,任何系统不再是孤岛,而是相互依赖。

  • 由于存在多系统间环境稳定难、数据准备难、并行开发难问题,造成多系统间联调测试需要大量的沟通成本、排查成本,为了解决这个让人头疼的问题,引出了挡板的概念。

  • 其实挡板测试主要是为了模拟外围的系统、服务、接口而开发的mock。

  • 如电商系统调用第三方支付进行订单的付款、充值等场景,在双方遵循接口协议的同时,不管开发联调、功能测试还是性能测试都能够让技术人员专注自己的功能实现。

  • 一句话为了减少外围系统的影响,在接口协议的框架下专注自己的功能,减少扯犊子。

解决问题

  • 挡板解决不稳定的问题:Mock服务非常简单,业务逻辑简单甚至没有,所以它足够稳定。

  • 快速构造复杂数据:通过自定义返回结果,可以构造非常复杂的数据,不需要第三方给我们准备数据,即可以做业务验证。

  • 快速构造异常场景:对于一些异常的情况,比如网络延迟高、重试机制、特殊异常返回都可以用挡板来构造。

  • 契约测试、驱动测试开发、性能测试、演示测试统统适用。

框架说明

  • 先复习下我们一般的系统架构,如下面的电商系统,会调用外围的系统支付、短信、实名认证、邮件通知等,对外通讯协议有tcp、https、http、smtp。

    这里主要说明了调用外围系统的情景,其实内部之间的系统调用比这复杂。

  • 我们在引入挡板的架构后,挡板对于核心系统访问应该是透明的,是美丽的谎言,具体架构如下:

  • 我们再深入了解下挡板的架构,从功能的角度分为动态数据、静态数据提供。

  • 挡板前置

    提供接口的服务,用什么协议、用什么端口。

  • 静态数据

    静态的数据就是在写测试代码之前根据需要准备好,利用json数据格式(1.2)或其他文件的数据(1.1)存储,他的数据基本上是无需改动的。

  • 动态数据

    动态的数据就是在测试代码运行的时候才生成或根据业务去查询数据。

    数据无上下文关系只是一些动态的数据输出,利用动态的函数生成数据(2.1),动态的函数生成可以使用mockjs,它里面提供常用的数据生成,后面详细介绍。

    数据存在上下文关系,当前接口需要前面接口数据,利用数据库或NOSQL提供数据(2.2)。

  • 定时回调

    利用定时任务能够根据业务需求定时的把数据推到核心系统,是整个业务调用链路闭合。他的数据来自于静态或动态的数据。

技术选型

根据上面的架构要求,在繁多的开源框架中,选择了 mountebank+mockjs ,其他的框架很多在这里不做评论,软件没有好坏之分,适合很重要!

mountebank本身相当强大,引用官方的话说mountebank可以提供跨平台、多协议的测试mock,就像我们所说的桩代码一样提供测试驱动, 是当前开源当中最强大的,完全可以治愈你的痛。

一句话mountebank简单便捷的实现挡板服务。

mountebank 简写 mb,注意后面直接称呼了。

mockjs本身也是可以提供mock的功能,我主要是看中了他强大的丰富的数据类型,生成动态的数据那是相当方便。 先尝尝鲜:

Mock.mock({ 
  "number|1-100": 100 
}) 

这个函数就很容易的得到1到100的随机数,相当方便吧。

环境安装

由于mountebank是基于nodejs,所以首先要安装下nodejs,版本要求是v6或以上(他的强大之处在这里就不多说了,做前端的朋友心里有数)。

官方下载https://nodejs.org/en/download/

或者

yum install -y nodejs 

那就mb和其他的组件一起安装了

#安装mountebank 
npm install -g mountebank 
#http请求提交试用 
npm install superagent --save 
#日志打印 
npm install log4js 
#获取UUID试用 
npm install uuid --save 
#获取时间 
npm install moment 
#mysql连接用 
npm install mysql -S 
#定时任务 
npm install cron 
#mock常用函数组件 
npm install mockjs 

mb概述

安装好mb以后,直接通过mb命令启动服务,默认端口是2525,浏览器输入( http://localhost:2525 ) 访问试试看!

在RHEL7和Centos7下可以设定后台服务自动启动,配置的方法可以在这里 配置方法 获得,在这里不多扩展。 本人喜欢写段shell脚本来启动、停止mb服务,详情见后面。

  • mb命令启动常用参数

|   选项| 描述| 默认| 
|-- |-- |-- | 
|   --command|  start,stop,restart, replay(删除代理,转化为代理捕获的响应值,后面代理详解)|    start| 
|   --port 2525|    指定mb运行的端口好,默认是2525| 2525| 
|   --configfile imposters.ejs| mountebank将加载指定的模板文件,为了方便管理会把配置存储在EJS模板中。后面详细说明。|   N/A| 
|   --logfile mb.log|   mountebank用于存储日志的文件|    mb.log| 
|   --loglevel debug|   日志级别 debug, info, warn, error|  info| 
|   --allowInjection|   是否允许脚本注入,mb支持针对谓词, 存根响应,行为装饰, 等待行为函数和 tcp请求解析的 JavaScript注入,但默认情况下禁用它们。|    false| 
|   --localOnly|    请求隔离,仅接受来自localhost的请求| false| 
|   --ipWhitelist| 白名单环境隔离| 所有IP地址| 
|   --pidfile|  为stop命令存储pid的文件|    mb.pid| 
  • 常用术语

    1. 冒名顶替者(imposter)

    从社会角度来说他就是一个诈骗团伙,里面会有很多的骗子。 从技术角度来说imposter就是一个服务或系统骗子,他有端口、协议提供测试的服务,可以根据测试需要创建多个imposter。

    1. 存根(stub)

    用于为imposter生成响应的一组配置。一个imposter可以具有0个或多个stub。

    从社会角度来说就是骗子公司的一个部门或产品线。

    从技术角度来说stub就是提供一个服务的响应,一个请求的地址。

    1. 谓词(predicate)

    配置响应stub的条件。每个stub可以有0个或更多predicate。

    从社会角度来说就是要找相应的人做相应的事。

    从技术角度来说predicate就是根据条件做相应的功能,返回对应的数据。

    1. 响应(response)

    产生响应数据。每个stub可以有0个或更多响应。

    1. 响应类型(response type) 每个stub响应由定义响应行为的特定响应类型定义。目前支持的响应类型是:is,proxy,inject。

    is是默认的类型,就是一般的应答输出。

    proxy是代理响应类型,它允许记录重放行为。

    inject允许您注入JavaScript函数以创建响应对象。

    1. 存根行为(stub behavior)

    向响应添加其他的自定义处理,如可以后面通过shell脚本执行nodejs来获取动态的数据。

    如果对上面的概念还不明白,没有关系,后面就让代码给你解释。

  • 代码说明一切

    前面对于他的结构说明了一番,不懂得没有关系,我们go on! 下面就是一个imposter,其实他就是一个json格式的文件,为了方便说明我在json里面加了注释(懂技术的人都会明白,这样会死的很惨!)。

{ 
  "port": 8081, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "predicates": [ 
        { 
          "equals": { 
            "method": "POST", 
            "path": "/customers/123" 
          } 
        } 
      ], 
      "responses": [ 
       { 
        "is": { 
            "statusCode": 200, 
            "headers": { 
                "Server": "Apache-Coyote/1.1", 
                "Access-Control-Allow-Origin": "*", 
                "Content-Type": "text/json;charset=UTF-8", 
                "Content-Length": 298, 
                "Date": "Tue, 05 Sep 2017 06:49:14 GMT", 
                "Connection": "close" 
            }, 
            "body": { 
        
                "head": { 
                    "rspcode": "100", 
                    "respMsg": "成功", 
                    "serialNo": "d7af14e5-a99e-4881-9b1b-695b7a760a64" 
                }, 
                "body": { 
                    "code": "S4393493", 
                    "startDate": "2019-04-09", 
                    "level": "3", 
                    "endDate": "2020-04-08" 
                } 
        
            } 
        }, 
        "_behaviors": { 
            "shellTransform": ["node ./dcs/test.js"] 
        } 
       } 
      ] 
    }, 
    { 
      "responses": [ 
        { 
          "is": { "statusCode": 404 } 
        } 
      ] 
    } 
  ] 
} 

正常访问的情况下返回结果为:

{ 
    "head": { 
        "rspcode": "100", 
        "respMsg": "成功", 
        "serialNo": "d7af14e5-a99e-4881-9b1b-695b7a760a64" 
    }, 
    "body": { 
        "code": "S4393493", 
        "startDate": "2019-04-09", 
        "level": "3", 
        "endDate": "2020-04-08" 
    } 
} 

是不是很简单?

mb详解

上面主要介绍了mb的环境安装及其关键术语,和一个简单的demo,那么接下来我们逐步了解。

其他语言客户端支持

如果你对nodejs不熟或者很倔不想接收新的语言,可以在下面找到自己的熟悉的语言,如果没有要自己去写了。 说实在的,对于有上心的开发者是难不倒的,一点就通!

|   语言| 工程| 作者| 
|-- |-- |-- | 
|C#                                 |[MbDotNet](https://github.com/mattherman/MbDotNet)                         |Matthew Herman             | 
|Clojure                        |[Charlatan](https://github.com/mdaley/charlatan)                   |Matthew Daley              | 
|Go                                 |[GoBank](https://github.com/durmaze/gobank)                            |Erkan                              | 
|Java                               |[javabank](https://github.com/thejamesthomas/javabank)                     |James Thomas                   | 
|JavaScript                 |[mountebank-helper](https://www.npmjs.com/package/mountebank-helper)   |Alex                                   | 
|Perl                               |[Test::Mountebank](https://metacpan.org/release/Test-Mountebank)       |Dagfinn Reiersøl           | 
|PHP                                |[Juggler](https://github.com/meare/juggler)                        |Andrejs Mironovs           | 
|Python                         |[mountepy](https://pypi.org/project/mountepy/)                     |Michał Bultrowicz      | 
|Ruby                               |[mountebank-gem](https://github.com/CoderKungfu/mountebank-gem)            |Michael Cheng              | 
|Shell                          |[mountebank-sh](https://github.com/sebero/mountebank-sh)           |Sergi Bech Robleda     | 
|TypeScript                 |[node-mountebank](https://www.npmjs.com/package/@toincrease/node-mountebank)       |Ron van der Wijngaard| 

启动脚本

看到这里应该明白了mb究竟是个什么东东,但怎么开发、启动一个自己的服务估计还有些迷糊,那么我们从启动说起。

mb start --configfile imposters.ejs  --allowInjection  & 

上面我说了常用的参数,这个启动指令里包含 start、–configfile、–allowInjection。

start 顾名思义就是启动mb

–configfile 后面跟的imposters.ejs就是模板文件,EJS是一个JavaScript模板库,用来从JSON数据中生成HTML字符串。 有时通过配置文件加载imposters更方便,而不是通过API(就是通过指令,我也嫌麻烦就没提)加载它们。 我们可以在EJS文件里面定义端口、通讯协议、存根等,对于多个服务的模块化开发相当便利。

–allowInjection 是否允许脚本注入,后面详解。

EJS文件脚本

本次讲解主要是http的挡板服务,由于时间有限,smtp、tcp请参照官方文档或等后续文档。

imposters.ejs文件的内容是json格式的数据,具体如下:

{ 
    "imposters": [ 
        , 
        , 
         
    ] 
} 

ejs内容比较简单,就是imposters的配置,它是json数组,通过include加载多个json文件来注入到imposters配置中,如test1.json是支付的挡板代码, test2.json是实名认证的挡板代码,这样从架构的角度,各司其职、分而治之不同的业务挡板不同的配置文件,便于并行开发和维护。

JSON文件脚本

test1.json文件json的格式如下,可以根据注释了解其整个结构。

{ 
    "port": 1234, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
                "equals": { 
                    "path": "/test/demo", 
                    "method": "post" 
                } 
            }, {}], 
        "responses": [{ 
            "is": { 
                "statusCode": 200, 
                "headers": { 
                }, 
                "body": { 
                } 
            }, 
            "_behaviors": { 
                "shellTransform": ["node ./test/test.js"] 
            } 
        }, {}] 
    }, 
    { 
        "responses": [{ 
            "is": { 
                "statusCode": 400, 
                "headers": { 
                }, 
                "body": { 
                } 
            } 
        }] 
    }] 
} 

注意   最后一个应答是没有predicate匹配,则发送默认响应,就是我们写代码是swich时的default。  

说起http协议,一定要说下状态码,有时候面试官也会经常问起,目前大多数服务化治理都是采用http通讯协议。

下面简单的提下,便于大家理解,技术这玩意也是个关系网,简单的服务也会涉及到很多领域。 做前端的兄弟们一定要记住,因为大多是中后台兄弟的问题,这个时候可以吊他们一次。

1开头系列:表示请求已接收,继续处理
100
​
2开头系列:成功--表示请求已被成功接收、理解、接受
200 ok 表示请求成功返回网页
​
3开头系列:表示重定向,要完成请求必须进行更进一步的操作
301 永久跳转
302 临时跳转,请求的网页已临时跳转到新位置。
​
4开头系列:客户端错误--请求有语法错误或请求无法实现
400 服务器不理解请求的语法。
401 请求要求身份验证。对于登录后请求的网页,服务器可能返回此响应。
403 表示用户得到授权(与401错误相对),但是访问是被禁止的,服务器收到请求但是拒绝提供服务
404 网页没有发现
406 用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
​
5开头系列:服务器端错误--服务器未能实现合法的请求
500 内部服务器错误
502 一般是网关服务器请求后端服务时,后端服务没有按照http协议正确返回结果。
503 服务当前不可用, 可能因为超载或停机维护。
504 一般是网关服务器请求后端服务时,后端服务没有在特定的时间内完成服务。

protocol常用设置

协议这块http默认就可以了,https的话最好配置下私钥和证书,这样核心系统调用时不会出现警告。

{ 
  "port": 1234, 
  "protocol": "https", 
  "key": "", 
  "cert": "", 
  ... 
} 
​ 

predicates常用配置

predicate决定存根是否匹配,不管怎样它返回就是一个boolean值,true或false,如果返回true就返回对应的response响应,只要这个清楚,那么下面的所有花哨都很一般。

常用运算符

|运算符            |描述                                                                                                                                                     | 
|-- |-- | 
|equals         |请求字段与谓词匹配                                                                                                                          | 
|deepEquals |在请求的数据包含某个关键字及对应的值(例如query,http中的字段)| 
|contains       |请求字段包含谓词                                                                                                                               | 
|startsWith |请求字段以谓词开头                                                                                                                          | 
|endsWith       |请求字段以谓词结束                                                                                                                          | 
|matches        |请求字段与正则表达式匹配。                                                          | 
|exists         |是否存在                                           | 
|not                |取反                                                                                                                                             | 
|or                 |满足其中一个条件                                                                                                               | 
|and                |同时满足条件                                                                                                             | 
|inject         |注入JavaScript以确定请求是否匹配                              | 

常用的条件设置

equals

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
            "equals": { 
                "path": "/test", 
                "method": "get" 
            } 
        }], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "equals" 
                } 
            } 
​ 
        }] 
    }] 
} 

浏览器访问后结果http://localhost:8081/test

{ 
    "Operator": "equals" 
} 

deepEquals

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
            "deepEquals": { 
                "query": { 
                    "key": ["first", "second"] 
                } 
            } 
        }], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "deepEquals" 
                } 
            } 
​ 
        }] 
    }] 
} 

浏览器访问后结果http://localhost:8081/test?key=second&key=first

{ 
    "Operator": "deepEquals" 
} 

exists

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
            "exists": { 
            "method": true, 
            "body": false 
          } 
        }], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "exists" 
                } 
            } 
​ 
        }] 
    }] 
} 

浏览器访问后结果http://localhost:8081/test

{ 
    "Operator": "exists" 
} 

还有其他场景判断

"exists": { 
            "method": true, 
            "body": false  
          } 
                     
"exists": { "body": true } 

inject 脚本注入设置

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
                "inject": "function (config) {return config.request.body.indexOf('inject') === 0;    }" 
            } 
        ], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "inject" 
                } 
            } 
​ 
        }] 
    }] 
}  ​

通过postman post方法提交, http://localhost:8081/test ,提交数据包含inject,返回结果如下:

{ 
    "Operator": "inject" 
} 

注意   inject的函数在这里不能进行编排,造成编写、调试代码不方便,可以采用下面导入文件的方式解决,包括responses注入也适应。  

inject利用stringify导入模板脚本

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
            "inject": "" 
        }], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "inject" 
                } 
            } 
​ 
        }] 
    }] 
} 

inject.ejs脚本

function(config) { 
    return config.request.body.indexOf('mounte') === 0; 
} 

注意   其他的操作都是对tcp数据的操作,在这里不做过多解释,有需要可以和我沟通或去官网去查帮助。  

xpath脚本 是挡板接收xml数据的时候进行条件的判断,由于我们的服务大多是基于restful的,所以这种场景使用不多,全凭烧下大脑,越来越聪明。

{ 
    "port": 8081, 
    "protocol": "http", 
    "stubs": [{ 
        "predicates": [{ 
            "equals": { 
                "body": "Harry Potter" 
            }, 
            "xpath": { 
                "selector": "//title" 
            }, 
            "caseSensitive": true, 
            "comment": "case sensitivity applies to the selector as well as the value" 
        }], 
        "responses": [{ 
            "is": { 
                "body": { 
                    "Operator": "xpath" 
                } 
            } 
​ 
        }] 
    }] 
}  ​

通过postman post方法提交, http://localhost:8081/ ,提交数据如下:

 
   
    Harry Potter 
    Dragons 
   
 

返回的结果如下:

{ 
    "Operator": "xpath" 
} 

caseSensitive区分大小写,true或false。

selector查找xml那个节点,本示例是查的title节点。

responses常用设置

响应这块主要分三部分:代理模式、脚本注入以及利用shell脚本的动态行为。

代理

代理是mb是responses的一个配置项,与注射相媲美,支持记录、缓存行为,可以对其head、body进行修改, 可轻松捕获测试场景的丰富测试数据集。 代理就是给个地址,代理访问下录制各种场景的响应报文,录制以后我们可以设为静态服务响应数据。

"stubs": [{ 
  "responses": [{ 
    "proxy": { 
      "to": "http://localhost:8083/mockjs" 
            .... 
    } 
  }] 
}] 

proxy参数如下:

|参数                             |默认         |类型|描述| 
|--                                 |--             |--|--| 
|to                                 |需要         |代理的url地址,带http的全路径 |定义请求应代理的源服务器。                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | 
|predicateGenerators|[]             |数组 |一组对象,**用于定义如何创建新存根的谓词**,就是为了生成谓词的配置,数组中的每个对象都定义了从中生成谓词的字段。请参阅下面 的示例。                                                                                                                                                                                                                                                                                                                   | 
|mode                               |proxyOnce|proxyOnce、proxyAlways、proxyTransparent|proxyOnce 相同的条件只访问代理地址一次,获取的响应数据会缓存在本地。proxyAlways缓存所有响应数据,要和mb replay 配合使用,否则缓存数据越来越多,proxyTransparent模式代理请求但不记录任何数据 | 
|addWaitBehavior        |false      |布尔|如果为true,则mountebank将以wait 与代理调用相同的延迟为响应添加行为。这在您希望模拟正在虚拟化的下游服务的实际延迟的性能测试场景中非常有用。                                                                                                                                                                                                                                         | 
|addDecorateBehavior|null           |字符串,JavaScript |对已缓存的响应修改数据                                                                                                                                                                                                                                                                                                                                                                                                                    | 

从这几个参数开始我们逐步学习下mb的代理功能。

predicateGenerators 请求的条件判断

predicateGenerators 数组中的每个对象都包含以下字段:

|参数                 |默认 |类型                 |描述                                                                                                                                                                                                                     | 
|--                     |--     |--                     |--             | 
|matches            |{}     |宾语         |用于定义如何创建新存根的谓词,关键是定义、新建,相当于代码生成模板| 
|caseSensitive|false|布尔                 |确定匹配是否区分大小写,包括查询参数等对象的key。| 
|except             |""     |串                      |定义在匹配之前从请求字段中剥离的正则表达式。| 
|xpath              |null   |宾语                 |(业务场景不多,可忽略)定义包含selector字符串的对象,以及可选的ns定义命名空间映射的 对象字段。谓词的范围仅限于请求字段中的选定值。| 
|jsonpath           |null   |宾语                 |业务场景不多,可忽略)定义包含selector字符串的对象。谓词的范围仅限于请求字段中的选定值。| 

如下面的代码设置matches的一个值为query。

"stubs": [{ 
  "responses": [{ 
    "proxy": { 
      "to": "http://localhost:8083/mockjs", 
      "predicateGenerators": [{ 
        "matches": { 
          "query": { "q": "mountebank" } 
        } 
      }] 
    } 
  }] 
}] 

那么访问 http://localhost:8082/mockjs?q=mountebank 以后生成的谓词如下:

"stubs": [ 
  { 
    "predicates": [{ 
      "equals": { 
        "query": { "q": "mountebank" } 
      } 
    }], 
    "responses": [{ 
      "is": { ... } 
    }] 
  } 
    ] 

如果是 http://localhost:8082/mockjs?q=mountebank1 ,那么生成的谓词是”query”: { “q”: “mountebank1” }

mode定义代理的行为

proxyOnce – 确保相同的请求(由谓词定义)永远不会被代理两次。mountebank仅记录每个请求的一个响应,并在下次请求谓词匹配时自动重放该响应。

proxyAlways – 将代理所有呼叫,允许为同一逻辑请求保存多个响应。利用这个模式必须及时把代理删除,否则生成的谓词将很多,

mb replay --port 2525 
#命令执行后,代理模式清除,只有录制的谓词提供服务 

proxyTransparent – 请求访问透传不做记录处理。

injectHeaders修改请求头

顾名思义就是通过代理请求而相应数据的http的head追加一些属性。

"responses": [{ 
               "proxy": { 
                   "to": "http://localhost:8083/mockjs", 
                   "mode": "proxyOnce", 
                   "addWaitBehavior": true, 
                   "injectHeaders": { 
                       "X-My-Custom-Header-One": "my first value", 
                       "X-My-Custom-Header-Two": "my second value" 
                   } 
                   }}] 

那么会在响应的head上追加了两行数据,具体如下:

Cache-Control →no-cache 
Postman-Token →5aa8b6d1-c6aa-4660-93b5-af416bb87b55 
host →localhost:8083 
cookie →BDSVRTM=0 
accept-encoding →gzip, deflate 
Connection →keep-alive 
X-My-Custom-Header-One →my first value 
X-My-Custom-Header-Two →my second value 
Date →Sun, 19 May 2019 15:28:21 GMT 
Transfer-Encoding →chunked 

mb考虑的很周到,通过代理可以录制谓词,录制的数据可以提供修改http的head、body,那么下面就是介绍下怎么修改响应的数据了。

addDecorateBehavior修改已保存的响应

"responses": [{ 
                "proxy": { 
                    "to": "http://localhost:8083/mockjs" 
                    }, 
                    "addDecorateBehavior": "" 
                } 
            }] 
//proxy.ejs代码,也是通过EJS模板调用,便于代码熟悉及维护 
function(request, response) { 
    response.body = response.body + ' DECORATED!123456'; 
    console.log("function(request, response)"); 
} 

那么生成的代码如下:

"_behaviors": { 
        "decorate": "function(request, response) {\r\n\tresponse.body = response.body + ' DECORATED!123456';\n\tconsole.log(\"function(request, response)\");\r\n}" 
      } 

addWaitBehavior

还有一个重要的参数,那就是addWaitBehavior,当为true时会记录代理请求的响应时间,方便录制的数据回放时比较逼真,说白了就是完全模拟回访。

"responses": [{ 
                "proxy": { 
                    "to": "http://localhost:8083/mockjs", 
                    "addWaitBehavior": true 
                } 
            }] 

那么生成的代码

"_behaviors": { 
        "wait": 534 
      } 
//534就是响应的时间 

最后mb的代理就这样结束了,为了全面的了解,我把它贴出来:

//代理demo 
{ 
    "port": 8082, 
    "protocol": "http", 
    "stubs": [{ 
            "predicates": [{ 
                "deepEquals": { 
                    "path": "/mockjs" 
                } 
            }], 
            "responses": [{ 
                "proxy": { 
                    "to": "http://localhost:8083/mockjs", 
                    "mode": "proxyOnce", 
                    "addWaitBehavior": true, 
                    "injectHeaders": { 
                        "X-My-Custom-Header-One": "my first value", 
                        "X-My-Custom-Header-Two": "my second value" 
                    }, 
                    "predicateGenerators": [{ 
                        "matches": { 
                            "query": { 
                                "q": "mountebank" 
                            } 
                        } 
                    }], 
                    "addDecorateBehavior": "" 
                } 
            }] 
        } 
​ 
    ] 
} 

http://localhost:8082/mockjs?q=mountebank2 访问生成的谓词代码:

{ 
    "predicates": [{ 
        "equals": { 
            "query": { 
                "q": "mountebank2" 
            } 
        } 
    }], 
    "responses": [{ 
        "is": { 
            "statusCode": 200, 
            "headers": { 
                "User-Agent": "PostmanRuntime/7.11.0", 
                "Accept": "*/*", 
                "Cache-Control": "no-cache", 
                "Postman-Token": "5aa8b6d1-c6aa-4660-93b5-af416bb87b55", 
                "host": "localhost:8083", 
                "cookie": "BDSVRTM=0", 
                "accept-encoding": "gzip, deflate", 
                "Connection": "keep-alive", 
                "X-My-Custom-Header-One": "my first value", 
                "X-My-Custom-Header-Two": "my second value", 
                "Date": "Sun, 19 May 2019 15:28:21 GMT", 
                "Transfer-Encoding": "chunked" 
            }, 
            "body": "{\n    \"head\": {\n        \"rspcode\": \"200\"\n    },\n    \"body\": {\n        \"city\": \"山南地区\"\n    }\n}", 
            "_mode": "text", 
            "_proxyResponseTime": 534 
        }, 
        "_behaviors": { 
            "wait": 534, 
            "decorate": "function(request, response) {\r\n\tresponse.body = response.body + ' DECORATED!123456';\n\tconsole.log(\"function(request, response)\");\r\n}" 
        } 
    }] 
} 

脚本注入

前面我们说了谓词的注入,其实就是大同小异。 那么我们看下它的参数属性有哪些

|领域 |描述 | |– |– | |request |请求数据都在这| |state |mountebank全局变量,每次重启mb都会清空| |callback |一般情况下函数有返回值,如果没有那就是通过异步处理,此时必须使用callback调用该参数。| |logger |log句柄的引用,在函数中可以直接使用输出日志|

那么我们先看下代码

{ 
    "port": 8084, 
    "protocol": "http", 
    "stubs": [{ 
        "responses": [{ 
            "inject": "" 
            }] 
    }] 
} 

注入模板脚本responses.ejs如下:

function(config) { 
    config.logger.info('origin called'); 
    config.state.requests = config.state.requests || 0; 
    config.state.requests += 1; 
        var ret={ 
        headers: { 
          'Content-Type': 'application/json' 
        }, 
        body: JSON.stringify({ count: config.state.requests }) 
      }; 
            return ret; 
} 

其实从代码我们可以看出,上面的参数都是通过config这个对象传递给模板脚本的

state可以存放json数据、基本数据类型等。

如果修改为 callback 那么代码如下:

function(config) { 
    config.logger.info('origin called'); 
    config.state.requests = config.state.requests || 0; 
    config.state.requests += 1; 
    var ret={ 
      headers: { 
        'Content-Type': 'application/json' 
      }, 
      body: JSON.stringify({ count: config.state.requests }) 
    }; 
    config.callback(ret); 
} 

动态行为

_behaviors这个参数通过上面的讲解,应该大家了解一二,他的任务就是修改响应的数据,那么他的行为如下:

_behaviors这个参数通过上面的讲解,应该大家了解一二,他的任务就是修改响应的数据,那么他的行为如下: 
 
|行为                     |描述                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | 
|--                         |--                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | 
|wait                       |响应延迟毫秒数| 
|repeat                 |在转到下一个响应之前,重复一定次数的响应。| 
|copy                       |将请求字段中的一个或多个值复制到响应中。您可以使用正则表达式,xpath或jsonpath标记响应并从请求字段中选择值。| 
|lookup                 |从请求的数据中作为key值从外部数据源去取数据。| 
|decorate               |**--allowInjection启动参数必须加**在发送之前使用JavaScript注入对响应进行后处理。| 
|shellTransform |**--allowInjection启动参数必须加**,不是使用JavaScript注入,而是将其发送到另一个应用程序,另一应用通过stdout输出JSON格式传递数据回来。                                                                                                                                                                                                                                            | 

那么我们接下来对上面的参数详细说明下:

wait参数

{ 
  "port": 8085, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "responses": [ 
        { 
          "is": { 
            "body": "This took at least half a second to send" 
          }, 
          "_behaviors": { 
            "wait": 500 
          } 
        } 
      ] 
    } 
  ] 
} 

通过 http://localhost:8085 访问后:

This took at least half a second to send 

repeat参数

{ 
  "port": 8085, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "responses": [ 
        { 
          "is": { 
            "body": "This will repeat 2 times" 
          }, 
          "_behaviors": { 
            "repeat": 2 
          } 
        }, 
        { 
          "is": { 
            "body": "Then this will return" 
          } 
        } 
      ] 
    } 
  ] 
} 

通过 http://localhost:8085 访问后:

//第一次结果 
This will repeat 2 times 
//第二次结果 
This will repeat 2 times 
//第三次结果 
Then this will return 

从上线的结果可以看出,如果没有”repeat”: 2话应该是轮询显示结果。加了以后就可以访问多少次后再往下轮询。

copy参数

{ 
  "port": 8085, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "responses": [ 
        { 
          "is": { 
            "body": "The request name was ${name}. Hello, ${name}!" 
          }, 
          "_behaviors": { 
            "copy": [ 
              { 
                "from": { "query": "name" }, 
                "into": "${name}", 
                "using": { 
                  "method": "regex", 
                  "selector": "MOUNT\\w+$", 
                  "options": { "ignoreCase": true } 
                } 
              } 
            ] 
          } 
        } 
      ] 
    } 
  ] 
} 

上面的代码其实就是利用请求的参数中,查找包含以mount开头的数据,然后把它替换掉name变量, 通过 http://localhost:8085/400?ignore=this&name=1mountebank23 访问以后,因为包含1mountebank23值,所以显示结果如下:

The request name was mountebank23. Hello, mountebank23!

如果访问 http://localhost:8085/400?ignore=this&name=1mou1ntebank23 ,因为没有匹配 那么结果如下:

The request name was ${name}. Hello, ${name}! 

lookup参数

这个参数就是从请求的数据中,取出某个值作为查询条件去外部数据源查找对应的值。 代码如下:

{ 
  "port": 8085, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "responses": [ 
        { 
          "is": { 
            "body": "Hello ${row}['Name'], have you done your ${row}['jobs'] today?" 
          }, 
          "_behaviors": { 
            "lookup": [{ 
              "key": { 
                "from": "path", 
                "using": { "method": "regex", "selector": "/(.*)$" }, 
                "index": 1 
              }, 
              "fromDataSource": { 
                "csv": { 
                  "path": "./test/values.csv", 
                  "keyColumn": "Name" 
                } 
              }, 
              "into": "${row}" 
            }] 
          } 
        } 
      ] 
    } 
  ] 
} 

那么外部数据源的文件内容如下:

values.csv 
State_ID,code,Name,price,tree,jobs 
1111111,400,liquid,235651,mango,farmer 
9856543,404,solid,54564564,orange,miner 
2222222,500,water,12564,pine,shepherd 
1234564,200,plasma,2656,guava,lumberjack 
9999999,200,lovers,9999,dogwood,steel worker 

那我通过 http://localhost:8085/water 访问时结果如下:

Hello water, have you done your shepherd today?

简单说明下为什么上面是index是1,懂正则的人一看就明白,但对初学者还是一个解释好,

如果路径是/water 通过下面的表达式

"using": { "method": "regex", "selector": "/(.*)$" } 
得到的其实是个数组{"/water",water}

 index为1就取得值为water

得到water后,然后再去values.csv文件获取到对应的行数据。

decorate参数

咱们前面在说代理是说到一个属性addDecorateBehavior,它就可以生成对应的decorate,它的的目的就是动态修改或追加响应的数据。 就是利用javascript脚本注入的方式来完成。

代码上来再说哈!

{ 
  "port": 8085, 
  "protocol": "http", 
  "stubs": [ 
    { 
      "responses": [ 
        { 
          "is": { 
            "body": "The time is ${TIME}" 
          }, 
          "_behaviors": { 
            "decorate": "(config) => { var pad = function (number) { return (number < 10) ? '0' + number : number.toString(); }, now = new Date(), time = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds()); config.response.body = config.response.body.replace('${TIME}', time); }" 
          } 
        } 
      ] 
    } 
  ] 
} 

通过 http://localhost:8085 访问后,结果如下:

The time is 23:06:29

当然也可以引入模板:

"_behaviors": { 
              "decorate": "" 
          } 

behaviors.ejs模板文件的内容:

function(config) { 
    var pad = function(number) { 
        return (number < 10) ? '0' + number: number.toString(); 
    }, 
    now = new Date(), 
    time = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds()); 
    config.response.body = config.response.body.replace('${TIME}', time); 
} 

shellTransform参数

shellTransform行为类似于decorate,可以对响应数据进行修改。但不同的地方是它通过shell命令调用外部的脚本, 执行时他会把request、response作为参数传给外部脚本,外部脚本拿到后可以对响应的数据进行修改,然后输出到控制台,这样mb就可以达到对应的值,千万注意控制台打印的是json格式。

具体代码如下:

{ 
    "port": 8086, 
    "protocol": "http", 
    "stubs": [{ 
        "responses": [{ 
            "is": { 
                "body": "Hello, ${city}!" 
            }, 
            "_behaviors": { 
                "shellTransform": ["node ./test/shellTransform.js"] 
            } 
        }] 
    }] 
} 

shellTransform.js文件如下

// 使用 Mock 
var Mock = require('mockjs') 
var request = JSON.parse(process.argv[2]), 
    response = JSON.parse(process.argv[3]); 
response.body = response.body.replace('${city}', Mock.mock('@city')); 
console.log(JSON.stringify(response)); 

那么通过 http://localhost:8086 访问后的结果如下:

Hello, 厦门市!

那么我们可以联想下,能够shellTransform.js使用外部数据源,解耦性更强了,只要外部输出json数据到控制台就可以拿到数据了。 shellTransform.js本身可以是nodejs,那么连接数据库、nosql都是分钟级的事情了,有包含上下文的业务场景挡板不就直接搞定了。

至此mountebank的使用已经讲解完了,有不明白的可以和我联系(微信号dcs678),加之前请备注mountebank或mb,否则拒绝通过。 接着看后面的实战干活哈,go on!

mockjs简介

  • 功能介绍

|主要功能   |   功能描述| 
|-- |-- | 
|前后端分离                      |让前端攻城师独立于后端进行开发。                                                                       | 
|增加单元测试的真实性 |通过随机数据,模拟各种场景。                                                                             | 
|开发无侵入                      |不需要修改既有代码,就可以拦截 Ajax 请求,返回模拟的响应数据。         | 
|用法简单                           |符合直觉的接口。                                                                                                       | 
|**数据类型丰富**                 |**支持生成随机的文本、数字、布尔值、日期、邮箱、链接、图片、颜色等。**  | 
|方便扩展                           |支持支持扩展更多数据类型,支持自定义函数和正则。                                       | 

其实人家也是可以直接mock的,根据名字也能看出,但我们可以使用他的丰富数据类型,说白了就是随机可以动态造数据,有兴趣同学可以研究下他们的功能。

// 声明变量  
var Mock = require('mockjs') 
​ 
​ 
//生成10个星 
Mock.mock({ 
  "string|1-10": "★" 
}) 
// => { "string": "★★★★★★★★"} 
​ 
//随机生成邮件地址 
var Random = Mock.Random 
Random.email() 
// => "n.clark@miller.io" 
Mock.mock('@email') 
// => "y.lee@lewis.org" 
Mock.mock( { email: '@email' } ) 
// => { email: "v.lewis@hall.gov" } 
​ 
//生成随机数字 
Mock.mock({ 
  "number|+1": 202 
}) 
// =>{"number": 201} 
​ 
// 生成uid 
Random.guid() 
Mock.mock('@guid') 
Mock.mock('@guid()') 
​ 
// =>{"number": 201}"6CeEb1D9-a54F-b90b-EA28-fD271E6eAe01" 
// =>{"number": 201}"7f11ef3A-d270-BafE-289f-f6B4DFCbD94f" 
// =>{"number": 201}"eCfD7F48-d8dE-9caB-cE77-bE5c6b16Fbd4" 

挡板实战

上面也算是详细介绍了mountebank、mockjs的用法,那么接下来介绍下我的挡板实现(结合上面的第三张图)

本次讲解的的挡板demo目录为baffle,他的目录结构如下:

|路径 |类型 |备注 | 
|-- |-- |-- | 
|baffle/logs    |目录 |日志文件目录,存放业务日志和定时任务日志   | 
|baffle/quartz  |目录 |存放定时任务,为了实现挡板的回调功能包含log.js(quartz专用)、testquartz.js | 
|baffle/test    |目录 |存放挡板服务实现   | 
|baffle/utils   |目录 |常用工具类,数据查询、日志输出等,包含db.js、log.js、utils.js   | 
|baffle/imposters.ejs   |文件 |启动参数   文件| 
|baffle/start.sh    |文件 |linux启动脚本,方便服务启动   | 
|baffle/stop.sh |文件 |linux停止脚本,方便服务停止   | 
|baffle/startMac.sh |文件|mac启动脚本,里面包含停止脚本| 

挡板回调

利用定时任务实现挡板回调 log.js代码如下:

/** 
 * 日志打印工具类 
 */ 
var log4js = require('log4js'); 
log4js.configure({ 
    appenders: { 
        file: { 
            type: "dateFile", 
            filename: './logs/quartz.log', //您要写入日志文件的路径 
            alwaysIncludePattern: true, //(默认为false) - 将模式包含在当前日志文件的名称以及备份中 
            //compress: true,//(默认为false) - 在滚动期间压缩备份文件(备份文件将具有.gz扩展名) 
            pattern: "-yyyy-MM-dd.log", //(可选,默认为.yyyy-MM-dd) - 用于确定何时滚动日志的模式。格式:.yyyy-MM-dd-hh:mm:ss.log 
            encoding: 'utf-8', //default "utf-8",文件的编码 
            maxLogSize: 10 //文件最大存储空间,当文件内容超过文件存储空间会自动生成一个文件xxx.log.1的序列自增长的文件 
        } 
    }, 
    categories: { 
        default: { 
            appenders: ['file'], 
            level: 'info' 
        } 
    } 
}); 
​ 
function info(obj) { 
    logger.info(obj); 
} 
var logger = log4js.getLogger('log_file'); 
module.exports = { 
    info 
} 

testquartz.js文件如下:

var logger = require('./log'); 
const cron = require('cron'); 
var db = require('../utils/db'); 
var utils = require('../utils/utils'); 
/** 
 * 定时任务 
 */ 
// https://www.npmjs.com/package/cron 
const CronJob = cron.CronJob; 
​ 
// CREATE TABLE `prduct` ( 
//   `id` int(255) NOT NULL AUTO_INCREMENT, 
//   `context` varchar(1000) NOT NULL, 
//   `flag` smallint(255) NOT NULL DEFAULT '0', 
//   PRIMARY KEY (`id`) 
// ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; 
//  
const prductParam = { 
    // cronTime: '*/10 * * * * *', 
    // 
    cronTime: '0 */5 * * * *', 
    onTick: function() { 
            db.query('SELECT * from prduct where flag=0', [], function(ret){ 
            if(ret.length>0){ 
                for(var row in ret){ 
                    var formData=ret[row].context; 
                    var parm=[]; 
                    parm.push(ret[row].id); 
                    var callRet = utils.call(utils.testUrl.prod.url, formData, function(results) { 
                        logger.info("callRet=" + JSON.stringify(results)); 
                        // console.log(results); 
                        db.query("update prduct set flag='1' where flag=0 and id=?", parm, function(err, retu){ 
                                logger.info("产品状态变更完成:"+parm); 
                                console.log(err); 
                        }); 
                    }); 
                } 
            }else{ 
                logger.info("没有对应的产品"); 
                console.log("没有对应的产品"); 
            } 
        }); 
    } 
}; 
// 产品定时推送接口 
const prductJob = new CronJob(prductParam); 
prductJob.start(); 

utils工具类

db.js代码如下

var logger = require('./log'); 
var mysql = require('mysql'); 
​ 
/** 
 * @param {Object} sql 
 * @param {Object} arr 
 * @param {Object} callback 
 * 执行sql 
 */ 
exports.query = function(sql, arr, callback) { 
    var connection=getConnection(); 
    //查 
    connection.query(sql,arr, function(err, result) { 
        if (err) { 
            logger.info('[SELECT ERROR] - ', err.message); 
            return; 
        } 
​ 
         callback && callback(result); 
    }); 
    connection.end(); 
}; 
/** 
 * 获取连接 
 */ 
function getConnection(){ 
    var connection = mysql.createConnection({ 
        host: '127.0.0.1', 
        user: 'root', 
        password: '123456', 
        database: 'test_mock' 
    }); 
    connection.connect(); 
    return connection; 
} 

log.js代码如下

/** 
* 日志打印工具类 
*/ 
var log4js = require('log4js'); 
log4js.configure({ 
    appenders: { 
        file: { 
            type: "dateFile", 
filename: './logs/mock.log', //您要写入日志文件的路径 
alwaysIncludePattern: true, //(默认为false) - 将模式包含在当前日志文件的名称以及备份中 
//compress: true,//(默认为false) - 在滚动期间压缩备份文件(备份文件将具有.gz扩展名) 
pattern: "-yyyy-MM-dd.log", //(可选,默认为.yyyy-MM-dd) - 用于确定何时滚动日志的模式。格式:.yyyy-MM-dd-hh:mm:ss.log 
encoding: 'utf-8', //default "utf-8",文件的编码 
maxLogSize: 10 //文件最大存储空间,当文件内容超过文件存储空间会自动生成一个文件xxx.log.1的序列自增长的文件 
} 
}, 
categories: { 
default: { 
appenders: ['file'], 
level: 'info' 
} 
} 
}); 
​ 
function info(obj) { 
logger.info(obj); 
} 
var logger = log4js.getLogger('log_file'); 
module.exports = { 
info 
} 

utils.js代码如下

var logger = require('./log'); 
var moment = require('moment'); 
moment.locale('zh-cn'); 
​ 
​ 
function exc(process) { 
    var request = JSON.parse(process.argv[2]), 
        response = JSON.parse(process.argv[3]); 
    var method = request.path.slice(1).replace(new RegExp("/","gm"), "_"); 
    var excutor = method + '(request,response)'; 
    logger.info(excutor); 
    return excutor; 
} 
​ 
function httpPost(url, formData,callback) { 
    var superagent = require('superagent'); 
    superagent 
        .post(url) 
        .send(formData) 
        .set('header_key', 'header_value') 
        .set('Content-Type', 'application/json') 
        .end(function(err, res) { 
            if (err) { 
                logger.info(err); 
                 callback && callback(""); 
                 // return ""; 
            } else { 
                var retData = JSON.parse(res.text) 
                 callback && callback(retData); 
                // return retData 
            } 
        }) 
} 
​ 
/** 
 * 默认的函数 
 * @param {Object} request 
 * @param {Object} response 
 */ 
function defaults(request, response) { 
     
    console.log(JSON.stringify(response)); 
    var reqData = request.body; 
    var reqJson = JSON.parse(reqData); 
    logger.info(reqJson); 
} 
/** 
 * 回调地址 
 */ 
var testUrl = { 
    prod: { 
        url: '/openapi/prodList', 
        description: '产品列表' 
    } 
}; 
​ 
function getUuid() { 
    // 声明变量  
    var Mock = require('mockjs'); 
    return Mock.mock('@guid'); 
} 
​ 
function getDateYYYYMMDD() { 
    return moment().format('YYYY-MM-DD'); /*现在的时间*/ 
} 
​ 
function getDateYYYYMMDDHHMMSS() { 
    return moment().format('YYYY-MM-DD HH:mm:ss'); /*格式化时间*/ 
} 
​ 
function formatDate(val) { 
    return moment(val).format('YYYY-MM-DD HH:mm:ss'); /*格式化时间*/ 
} 
​ 
function subDay(val) { 
    var _today = moment(); 
    return _today.subtract(val, 'days').format('YYYY-MM-DD'); /*前一天的时间*/ 
} 
​ 
function addDay(val) { 
    var _today = moment(); 
    return _today.add(val, 'days').format('YYYY-MM-DD'); /*明天天的时间*/ 
} 
​ 
/** 
 * @param {Object} url 
 * @param {Object} formdata 
 * 调用其他项目的接口 
 */ 
function call(url, formdata, callback) { 
    logger.info(url); 
    logger.info(formdata); 
    var postUrl = "http://localhost:8082" + url; 
    logger.info("postUrl=" + postUrl); 
    httpPost(postUrl, formdata, callback); 
} 
​ 
module.exports = { 
    exc, 
    httpPost, 
    defaults, 
    testUrl, 
    getUuid, 
    getDateYYYYMMDD, 
    getDateYYYYMMDDHHMMSS, 
    formatDate, 
    subDay, 
    addDay, 
    call 
}  ​

挡板实现

存放挡板服务实现,请参照上面讲的案例或到 github 拉取。

imposters.ejs配置文件

{ 
   "imposters": [ 
, 
, 
, 
, 
, 
, 
 
  ] 
} 

start.sh linux启动脚本

利用node的npm start也是可以的,这里自己写的目的是把定时任务也包含在里面。

#停止服务 
mb stop 
#---------------------------定时任务 start----------------------------- 
#先停止进程 
ps -ef|grep testquartz |grep -v grep|cut -c 9-15|xargs kill -9 
#再启动进程 
node quartz/testquartz.js & 
#---------------------------定时任务 end----------------------------- 
#启动服务 
mb start --configfile imposters.ejs  --allowInjection >test.out 2>&1 & 
​ 

stop.sh linux停止脚本

mb stop 
#---------------------------定时任务 start----------------------------- 
ps -ef|grep testquartz |grep -v grep|cut -c 9-15|xargs kill -9 
#---------------------------定时任务 end----------------------------- 
netstat -antp|grep 2525 |grep -v grep|cut -c 80-85|xargs kill -9 

DEMO代码

完整的代码demo已经传到Git,在这里。

总结

本文主要是先从当前的微服务架构说起,调用外围系统给开发、测试、演示的痛点,给出挡板的架构规划,然后利用mountebank的强大功能,通过脚本+实战的方式一步步详细说明。

Mark下当前时间2019-05-22 01:29:53以示完工庆祝,文中不当之处请不吝指出,不胜感激!

不知读到最后的您有没收获? 用mountebank来做挡板其实很简单,但必须多实践才能更好的掌握其精髓,更好的进行适合自己业务的扩展。

作者:一盘花生米

作者简介:

网名一盘花生米,八年的外企及十年多的国内知名支付行业的从业经验,担任过科长、资深架构、首席架构师、技术总监等要职,有着丰富 的系统架构、团队管理经验,在敏捷开发、Devops有独特见解和实战经验,在系统架构(技术架构、业务架构)有着丰富的经验,是一位名 副其实的实战家,曾主导基础化建设、服务化建设、软件工程化建设,有着自己的一套降本增效Devops体系。

【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】

【责任编辑:庞桂玉 TEL:(010)68476606】