技术创想111| BNPL白标项目中MockServer的应用详解

文摘   科技   2024-11-14 17:18   北京  

前言

我们在进行业务开发或测试时是否遇到过这些痛点:

  • 想在测试环境构造 edge case 验证程序的正确性,却只能依赖下游造 case。

  • 想部署在测试环境测试,下游接口还没准备好。

  • 下游接口还没有开发完成,上游却要找你联调接口连通性。

  • 测试想看实际的请求和响应,检查是否传递参数。

  • 想要对特定请求mock响应,其他请求正常转发。不影响其他人使用测试环境。

我们 BNPL 白标项目需要同时和外部电商平台和内部对接,作为业务端,我们会更频繁遇到这些问题。我们引入了 MockServer 来解决这些问题。

MockServer 有哪些能力

  • 作为 mock 服务: 可以对于不同的请求,mock 返回特定的响应。

  • 作为代理服务:记录和可选的修改请求和响应。

  • 同时代理某些请求和 mock 其它请求。

  • 支持 REST API、Java、JavaScript 来控制功能。

  • 有自带 UI 界面,可以显示配置的 mock 规则、接收到的请求、代理的请求。

  • 支持检索请求、响应等。

mock request

forward request

在项目中的实际应用

版本: java 8、okhttp 3.14.9、mockserver 5.14.0

mockserver官网:https://www.mock-server.com/

2.1 Java 服务如何开启http代理

我们通过 JDK 内置的sun.net.spi.DefaultProxySelector类来配置全局代理。这样的好处就是我们不需要替换下游的接口地址为 mockServer 的地址,也不需要配置 mockServer 的接口转发规则,请求打到 mockServer 上会自动的请求真实服务返回响应。

DefaultProxySelector java.net.ProxySelector 的默认实现类。我们项目中用到的 OkHttpClient 和  JDK 自己的 sun.net.www.protocol.http.HttpURLConnection 都会默认调用 ProxySelector#getDefault 方法来获取到 ProxySelector 对象再通过ProxySelector#select 拿到具体的代理地址。DefaultProxySelector#select 会从java.lang.System#props 实时读取系统配置中 http.proxyHost 和 http.proxyPort 两个变量生成 Proxy 代理对象。

下面是我们项目中的配置,可以通过 api 调用,随时开启和关闭代理。为什么不在项目启动时就打开代理呢?因为线上环境我们肯定是直连下游服务没有经过代理服务器,一直使用代理难免和线上环境产生差异,还有当我们想开启或者关闭代理时必须修改项目配置并重启服务,耗费的时间比较多。

/** * 只在测试环境生效 */@RestController@Profile("test")class MockProxyController {
private static final String HTTP_PROXY_HOST = "http.proxyHost"; private static final String HTTP_PROXY_PORT = "http.proxyPort";
/** * feign okHttpClient 的连接池。开启/关闭代理时把缓存的连接干掉。防止代理开关不生效 * * @see org.springframework.cloud.openfeign.FeignAutoConfiguration.OkHttpFeignConfiguration */ @Autowired(required = false) ConnectionPool connectionPool;
/** * 设置代理 */ @RequestMapping("openProxy") ResponseBean openProxy() { // 设置你的 mockServer host System.setProperty(HTTP_PROXY_HOST, "mock-server"); // 设置你的 mockServer port System.setProperty(HTTP_PROXY_PORT, "1080");
Optional.ofNullable(connectionPool) .ifPresent(ConnectionPool::evictAll);
return ResponseBean.success(); }
/** * 关闭代理 */ @RequestMapping("closeProxy") ResponseBean closeProxy() {
Properties properties = System.getProperties();
properties.remove(HTTP_PROXY_HOST); properties.remove(HTTP_PROXY_PORT);
Optional.ofNullable(connectionPool) .ifPresent(ConnectionPool::evictAll);
return ResponseBean.success(); }}

2.2 使用场景

在开启了代理后服务所有的 http 请求都会经过 mockServer 进行转发。接下来需要创建 mock 规则来得到我们的期望响应。创建 mock 规则是容易的,MockServer UI 上显示的 json 结构就是 mock 规则的结构。直接复制代理的请求即是一个简单 mock 规则。

2.2.1 mock 某个用户返回特定的响应数据
curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "httpRequest": {        "method": "GET",        "path": "/amounts-query-xx",        "queryStringParameters": {          "userId": [            "U659CFDBEB2F42F000137B780"          ]        }      },      "httpResponse": {        "statusCode": 200,        "body": {          "unsettledOverpaidAmount": 9999999,          "unpaidAmounts": []        }      }    }'

对于 userId 是 U659CFDBEB2F42F000137B780 的会命中 mock 规则返回期待的响应。其它 userId 则会代理请求到真实的服务上去。并显示不匹配 mock 规则的原因。

命中 mock 规则返回期待的响应

未命中 mock 规则显示未命中原因

2.2.2 调用下游接口500

我们可以设置响应的状态码 statusCode 来决定返回的 http 响应码是什么。

curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "httpRequest": {        "method": "GET",        "path": "/amounts-query-xx"      },      "httpResponse": {        "statusCode": 500      }    }'
2.2.3 模拟调用下游接口超时但请求实际被处理

mockServer 可以通过 httpOverrideForwardedRequest 对象设置 delay 来控制延时请求转发下游接口。

下面这个例子,请求到达 mockServer,mockServer 会等待 30 秒再代理转发给下游返回实际的响应。我们把延时时间设定的大于服务请求超时时间即可触发这个 case。

curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "id": "amounts2",      "httpRequest": {        "method": "GET",        "path": "/amounts-query-xx",        "queryStringParameters": {          "userId": [            "U659CFDBEB2F42F000137B780"          ]        }      },      "httpOverrideForwardedRequest": {        "httpRequest": {},        "delay": {          "timeUnit": "SECONDS",          "value": 30        }      }    }'
2.2.4 调用下游下单接口 500,通过补偿再次调用请求成功,测试补偿逻辑

当调用下游下单接口发生错误时,我们会进行下单补偿重试下游接口,所以我们的测试 case 是期望第一次请求下游报错,补偿请求下游成功或者补偿重试几次后请求下游成功。

mockServer 支持 mock 规则匹配生效次数以及 mock 规则生效时间。所以我们可以配置下单 mock 规则返回 500 只生效一次。

curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "id": "mock_batch_loan_orders_500_once",      "httpRequest": {        "method": "POST",        "path": "/batch-loan-orders",        "body": {          "userId": "U659E07BC19A71500012BE2D0"        }      },      "httpResponse": {        "statusCode": 500      },      "times": {        "remainingTimes": 1,        "unlimited": false      }    }'

配置完 mock 规则后我们进行下单请求,匹配的 userId 请求下游 /batch-loan-orders 接口会返回期望的结果,然后这个 mock 规则就被移除了。30 秒后补偿调用下游接口就会直接转发到真实的服务上去返回正常响应补偿成功了。

 mockServer 可以支持同时指定生效次数和生效时间。下面这个例子是 mock 支持 60 秒内匹配 3 次。超过 60 秒或者匹配超过 3 次,mock 规则就会失效,它的清除是懒清除,在有请求打到 mockServer 时校验是否匹配 mock 规则时才会清除。

curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "id": "mock_batch_loan_orders_500_thrice_or_60_seconds",      "httpRequest": {        "method": "POST",        "path": "/batch-loan-orders"      },      "httpResponse": {        "statusCode": 500      },      "times": {        "remainingTimes": 3,        "unlimited": false      },      "timeToLive": {        "timeUnit": "SECONDS",        "timeToLive": 60,        "unlimited": false      }    }'
2.2.5 下游接口未开发好 mock 返回下游结果

我们遇到过下游还未开发好,外部平台就要和我们联调的情况,我们使用 mockServer 的模版功能进行 mock 规则设定,暂时和外部进行联调,等待下游接口开发好再移除 mock 规则。

mockServer 的模版功能具有强大的自定义能力。mockServer 支持: mustache templates、velocity templates、javascript templates 三种模版方式。需要注意的是: Java8 只支持 JavaScript 的 ES5 语法。更多版本支持可以在官网查看Mock Server JavaScript Response Templates

下面的案例是基于Java8的 javascript templates 使用,假设请求下游接口的参数是

{  "projectId": "TP909E4DD37670000001",  "businessType": "BNPL",  "requestId": "Request-rhythm",  "orderType": "LOAN_ORDER",  "eventCode": "LOAN_ADVISORY",  "userId": "U659E3AD39C91000122E89D"}

下游接口的响应结构定义

{    "code": "SUCCESS",    "message": "string",    "data": {        "priceResultId": "string",        "previousPriceResultId": "string",        "expireTime": "2019-08-24T14:15:22Z",        "priceInfo": {            "priceResultStatus": "APPROVED",            "rejectReason": "",            "financeProductItems": [                {                    "financeProductItemId": "string",                    "priceItems": [                        {                            "feeType": "INTEREST",                            "priceType": "AMOUNT",                            "priceRate": 0,                            "priceContractRate": 0                        }                    ]                }            ]        },        "userId": "string",        "projectId": "string",        "businessType": "string",        "orderType": "string",        "eventCode": "string",        "createTime": "2019-08-24T14:15:22Z",        "requestId": "string"    }}

根据业务逻辑写 js 脚本填充参数

var requestBody = request.body;if (typeof request.body === 'string') requestBody = JSON.parse(request.body);var date = new Date();var financeProductItems = [];var productItem = ['TP1', 'TP3', 'TP6'];for (var i = 0; i < productItem.length; i++) {    var random = (Math.floor(Math.random() * 5) + 1) / 100;    financeProductItems.push({        'financeProductItemId': productItem[i],        'priceItems': [{            'feeType': 'INTEREST_RATE',            'priceType': 'PERCENTAGE',            'priceRate': random,            'priceContractRate': random        }]    });}var returnBody = {    'code': 'SUCCESS',    'message': 'Success',    'data': {        'priceResultId': date.getTime() + '_' + Math.floor(Math.random() * 10000),        'createTime': date.toISOString(),        'expireTime': new Date(date.getTime() + 86400000).toISOString(),        'priceInfo': {            'priceResultStatus': 'APPROVED',            'rejectReason': null,            'financeProductItems': financeProductItems        },        'requestId': requestBody.requestId,        'userId': requestBody.userId,        'projectId': requestBody.projectId,        'businessType': requestBody.businessType,        'orderType': requestBody.orderType,        'eventCode': requestBody.eventCode    }};return {statusCode: 200, body: returnBody, headers: {'Content-Type': 'application/json'}};

设置 mock 规则,对 POST 请求的 /calc-price 接口使用 JavaScript 模版返回

curl -X PUT --location 'http://localhost:8010/mockserver/expectation' \-H 'Content-Type: application/json' \-d '{      "id": "USER_CALC_PRICE",      "httpRequest": {        "method": "POST",        "path": "/calc-price"      },      "httpResponseTemplate": {        "templateType": "JAVASCRIPT",        "template": "var requestBody = request.body;if (typeof request.body === '\''string'\'') requestBody = JSON.parse(request.body);var date = new Date();var financeProductItems = [];var productItem = ['\''TP1'\'', '\''TP3'\'', '\''TP6'\''];for (var i = 0; i < productItem.length; i++) {var random = (Math.floor(Math.random() * 5) + 1) / 100;financeProductItems.push({'\''financeProductItemId'\'': productItem[i], '\''priceItems'\'': [{'\''feeType'\'': '\''INTEREST_RATE'\'', '\''priceType'\'': '\''PERCENTAGE'\'', '\''priceRate'\'': random, '\''priceContractRate'\'': random}]});}var returnBody = {'\''code'\'': '\''SUCCESS'\'', '\''message'\'': '\''Success'\'', '\''data'\'': {'\''priceResultId'\'': date.getTime() + '\''_'\'' + Math.floor(Math.random() * 10000), '\''createTime'\'': date.toISOString(), '\''expireTime'\'': new Date(date.getTime() + 86400000).toISOString(), '\''priceInfo'\'': {'\''priceResultStatus'\'': '\''APPROVED'\'', '\''rejectReason'\'': null, '\''financeProductItems'\'': financeProductItems}, '\''requestId'\'': requestBody.requestId, '\''userId'\'': requestBody.userId, '\''projectId'\'': requestBody.projectId, '\''businessType'\'': requestBody.businessType, '\''orderType'\'': requestBody.orderType, '\''eventCode'\'': requestBody.eventCode}};return {statusCode: 200, body: returnBody, headers: {'\''Content-Type'\'': '\''application/json'\''}};"      }    }'

当我们请求下游时会命中 mock 规则返回模版生成的响应

{  "code": "SUCCESS",  "message": "Success",  "data": {    "priceResultId": "1704880070153_2971",    "createTime": "2024-01-10T09:47:50.153Z",    "expireTime": "2024-01-11T09:47:50.153Z",    "priceInfo": {      "priceResultStatus": "APPROVED",      "rejectReason": null,      "financeProductItems": [        {          "financeProductItemId": "TP1",          "priceItems": [            {              "feeType": "INTEREST_RATE",              "priceType": "PERCENTAGE",              "priceRate": 0.02,              "priceContractRate": 0.02            }          ]        },        {          "financeProductItemId": "TP3",          "priceItems": [            {              "feeType": "INTEREST_RATE",              "priceType": "PERCENTAGE",              "priceRate": 0.04,              "priceContractRate": 0.04            }          ]        },        {          "financeProductItemId": "TP6",          "priceItems": [            {              "feeType": "INTEREST_RATE",              "priceType": "PERCENTAGE",              "priceRate": 0.04,              "priceContractRate": 0.04            }          ]        }      ]    },    "requestId": "Request-rhythm",    "userId": "U659E3AD39C91000122E89D",    "projectId": "TP909E4DD37670000001",    "businessType": "BNPL",    "orderType": "LOAN_ORDER",    "eventCode": "LOAN_ADVISORY"  }}
2.2.6 检索请求

下单成功后我们会给外部平台异步发送回调。QA 想验证回调请求体的正确性只能从日志里查看。我们 QA 有一套自动化测试脚本,通过 mockServer 检索请求接口可以将回调参数的验证集成到自动化测试中。

假设我们的回调请求体是这样的

curl -X POST --location 'http://xxxx/payment_callback' \    -H 'Content-Type: application/json' \    -d '{          "requestId": "P1704883870273-95600",          "orderId": "O659E769E9AAFB000012F6B0C",          "totalAmount": 2000,          "status": "SUCCESS"        }'

可以通过 mockServer /retrieve接口检索到该请求,根据请求 path 和具体的 body 参数查询匹配。

curl -X PUT --location 'http://localhost:8010/mockserver/retrieve' \    -H 'Content-Type: application/json' \    -d'{         "method": "POST",         "path": "/payment_callback",         "body": {           "requestId": "P1704883870273-95600"         }       }'

响应结果,忽略了一些无用的字段。可以根据 body.json 响应结果做自动化验证。

[  {    "body": {      "contentType": "application/json",      "type": "JSON",      "json": {        "requestId": "P1704883870273-95600",        "orderId": "O659E769E9AAFB000012F6B0C",        "totalAmount": 2000,        "status": "SUCCESS"      }    }  }]

踩坑

在实际使用中我们遇到了一些问题,这里给出一下我们的解决方法仅供参考。

3.1 sun.net.spi.DefaultProxySelector 实现类不代理本地请求

DefaultProxySelector#select方法的实现逻辑不会代理 "localhost|127.*|[::1]|0.0.0.0|[::0]" 和 http.nonProxyHosts 配置的变量

NonProxyInfo Class

DefaultProxySelector#select 部分逻辑

清楚了 DefaultProxySelector#select 方法的实现逻辑我们就有解决方法了。

3.1.1 解决方法一 配置 /etc/hosts 文件

比方我将 rhythm 域名映射到 127.0.0.1 然后将请求本地接口的 host 换为 rhythm 即可进行代理

3.1.2 解决方法二 自定义 ProxySelector 实现类

写一个新的 ProxySelector 实现类。在一切启动前设置完成,因为有些地方可能会通过 java.net.ProxySelector#getDefault 方法获取 ProxySelector 类进行缓存。这时候再设置就对已缓存 DefaultProxySelector 默认实现类的 http 客户端就不生效了。比方说 OkHttpClient 就会在创建 OkHttpClient 对象时把 ProxySelector 类创建好了。

@SpringBootApplicationpublic class TestApplication {    public static void main(String[] args) {        ProxySelector.setDefault(new ProxySelector() {            @Override            public List<Proxy> select(URI uri) {                if (System.getProperty("http.proxyHost") != null && System.getProperty("http.proxyPort") != null) {                    return Collections.singletonList(                        new java.net.Proxy(                            java.net.Proxy.Type.HTTP,                            new InetSocketAddress(                                System.getProperty("http.proxyHost"),                                Integer.parseInt(System.getProperty("http.proxyPort"))                            )                        )                    );                } else {                    return Collections.singletonList(Proxy.NO_PROXY);                }
}
@Override public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
} }); // do others SpringApplication.run(TestApplication.class, args); }}

3.1.2 MockProxyController 动态开启/关闭代理不生效

最开始我们没有在动态开启/关闭代理时把 OkHttpClient 连接池的连接清除,我们发现是否开启代理和创建连接时调用ProxySelector#select方法得到的 Proxy 对象里面的代理信息一致,后续我们改变 http.proxyHost 属性都不会生效。

其根本原因是因为我们动态改变系统变量中 http.proxyHost 的值。一般来说系统变量在程序启动后就不会发生变化了,正巧 OkHttpClient 连接池复用的条件是建立在 ProxySelector 对象相同,ProxySelector#select方法得到的 Proxy 对象也相同来判断的。只要 ProxySelector 对象相同就可以复用,没有考虑不同系统变量,相同 ProxySelector 对象的 select 方法获取到的 Proxy 对象代理信息不一致的情况。

我们通过 OkHttpClient 的源码看下连接复用的条件是什么?

okhttp3.internal.connection.RealConnection#isEligible这个方法检查连接是否可以复用。

这个方法只检查了已创建连接 RealConnection 的 Route 对象的 address 是否和新的请求的 address 是否一致。并没有检查已创建的route.proxy Proxy 对象的信息,而最后创建连接时是使用的route.proxy代理信息。所以我们要清楚route.proxy的创建来源。

route.proxy Proxy 对象本质是在 okhttp3.internal.connection.RouteSelector#resetNextProxy方法中被创建的,后续经过okhttp3.internal.connection.RouteSelector#next方法以及okhttp3.internal.connection.RouteSelector.Selection#next生成最终的 Route 对象。route.proxy 的创建来源有两个,一个是 address 的 proxy 字段,另一个是 address 的 proxySelector 字段,在创建 RouteSelector 对象的时候如果 address.proxy == null 就会使用 address.proxySelector#select来生成 Proxy 对象。否则直接使用 address 的 proxy。

我们清楚了route.proxy 的创建来源都是新请求的 address。RealConnection#isEligible 会检查 address 是否相等,对于 address 对象它检查了什么属性,我们看下 Internal.instance.equalsNonHost 方法的逻辑。通过 debug,可以看到最后底层是调用okhttp3.Address#equalsNonHost 方法。

我们发现Address#equalsNonHost方法中比较了两个 Address 对象 中 proxy 对象是否相等和 proxySelector 对象是否相等。对于 proxySelector 对象都是 DefaultProxySelector 相同的对象,所以

Internal.instance.equalsNonHost(this.route.address(), address)这个方法调用会返回 true。

address.url().host().equals(this.route().address().url().host())host 的比较也是 true,所以RealConnection#isEligible方法会返回 true,得出结论连接可以复用。我们没有看到已有的 route.proxy address.proxySelector#select生成的实际 Proxy 对象进行比较。所以连接一直可以被复用。

结语

以上就是我们项目对于 MockServer 的使用场景以及使用经验。MockServer 还有很多强大的功能我们没有用到,像正则匹配、https 的代理、校验功能等等。后续我们会更深入地使用 MockServer,从实践中发现更多好用的方法和功能。


关于领创集团

(Advance Intelligence Group)
领创集团成立于 2016年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的数字金融服务平台 Atome 等。2021年 9月,领创集团宣布完成超4亿美元 D 轮融资,融资完成后领创集团估值已超 20亿美元,成为新加坡最大的独立科技创业公司之一。




领创集团Advance Group
领创集团是亚太地区AI技术驱动的科技集团。
 最新文章