为了mock数据,我实现了一个简单的http代理

2,673 阅读6分钟

前言

最近在做一个需求:

项目有一个多线程的执行流程(涉及多个后端接口)是轮询执行的,需要在特定情况下降低请求频率。譬如在http状态码返回5xx的情况。

由于是子线程轮询执行的,属于纯数据层的逻辑,所以涉及的情况比较复杂。自测时如果利用Charles进行mock,只能进行固定模拟,而笔者想实现的效果类似:

模拟后端服务出问题,一段时间后请求正常。

所以就想到了实现一个类似Charleshttp代理功能。在这个代理服务器代码中对于固定的几个接口进行mock。本文属于HTTP 代理原理及实现(一)的阅读笔记,详细的原理分析可以结合该文阅读。顺便研究了一下Charles的实现原理。本文使用Node.js实现代理服务器的代码,读者也可以利用其他语言根据下述介绍的原理实现。

ps:本文只针对http代理,暂不讨论https的情况,后续提及对于https的请求如何处理。

Charles实现原理

Charles实现原理可以阅读文章charles代理原理简析。简单来说,Charles本质上就是http劫持。借助http通信原理,由于对称加密,客户端和服务端通信的过程容易被中间人篡改,就像被中间商赚了差价一样🤪。

正常的通信

graph LR
Client --request---> Server
Server --response---> Client

代理服务器所饰演的角色就是这个中间商,客户端会先经过代理,再由代理与服务端通信,最后代理会将通信的结果返回给客户端。由于http是对称加密,通信的数据对于代理服务器等同于明文的,所以代理就可以按照自己的设置来修改通信数据

graph LR
Client --request---> Proxy --request---> Server
Server --response---> Proxy --response---> Client

所以,回到笔者想要实现的效果是

  • 在http请求时,在Proxy代理服务器这一层修改response信息并返回给Client客户端
  • 除此之外,需要保证https的请求正常

http代理原理

要实现上述提出的效果,涉及到两种类型的代理,1、普通代理;2、隧道代理

普通代理

普通代理即上述所说的中间商赚差价方式客户端向代理服务器发送请求报文,代理服务器会收到报文后需要根据里面的内容处理与目标服务器的请求,或mock数据返回给客户端。就有了以下图和代码:

graph LR
Client --request---> Proxy --request---> Server
Server --response---> Proxy --response---> Client
var http = require('http')
var net = require('net')
var url = require('url')

function request(cReq, cRes) {
    var u = url.parse(cReq.url)

    var options = {
        hostname : u.hostname, 
        port     : u.port || 80,
        path     : u.path,       
        method     : cReq.method,
        headers     : cReq.headers
    }

    console.log(new Date() + 'request接收到请求:' + cReq.url)
    
    var pReq = http.request(options, function(pRes) {
        cRes.writeHead(pRes.statusCode, pRes.headers)
        pRes.pipe(cRes)
    }).on('error', function(e) {
        console.log("normalRequest发生错误:" + e)
        cRes.end()
    });

    cReq.pipe(pReq)
}

http.createServer()
    .on('request', request)
    .listen(8007, '0.0.0.0')
  • 创建一个服务监听8007端口(端口号根据实际情况设置),监听request事件。
  • request事件,通过客户端发送过来的请求解析出请求内容,并发起一次对于目标服务器的请求,将响应数据回传给客户端。

隧道代理

隧道代理在这个http代理中主要是用于处理https请求的,即保证https请求不受影响。它的定义是:“HTTP客户端通过CONNECT方法请求隧道代理创建一条到达任意目的服务器和端口的TCP连接,并对客户端和服务器之间的后继数据进行盲转发。”注意这里是盲转发,即代理服务器Proxy对于此https请求过程中请求的数据内容是不可知的,它只是起到一个传递的作用。

就好比“寄快递”,卖家给买家发货后,快递物流是不可能拆开你快递包装查看里面东西的,它只是作为买卖双方之间的运输角色而已。

所以在隧道代理这一场景下,Proxy收到CONNECT请求后,会与目标服务器请求打开一条TCP连接(ps:一般是访问服务器的443端口),后续客户端和目标服务器会直接通过这一连接通信,传输的内容是讲过双方的非对称加密的。对于Proxy来说,只能得到目标服务器的地址与端口号,通信内容是无法解密的。

image.png

根据以上解析,隧道代理的代码如下

function connect(cReq, cSock) {
    var u = url.parse('http://' + cReq.url);
    console.log("connect接收到请求:" + cReq.url)
    var pSock = net.connect(u.port, u.hostname, function() {
        cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
        pSock.pipe(cSock);
    }).on('error', function(e) {
        cSock.end();
    });

    cSock.pipe(pSock);
}

http.createServer()
    .on('connect', connect)
    .listen(8007, '0.0.0.0');
  • 创建服务,监听connect事件
  • connect事件接收后,会根据目标服务器的地址及端口号建立socket连接,并与客户端发送过来的socket建立通道。

需要注意的是,connect事件对于此http代理来说只会在https请求时触发。看了一些资料说是由于代理服务器检查到该请求是一个https,自身无法处理,所以自动处理成CONNECT方法的形式。这里利用OkHttp的源码分析一下它触发的时机(ps:使用的版本是3.14.9),请求www.baidu.com

String url = "https://www.baidu.com";
OkHttpClient okHttpClient = new OkHttpClient.Builder().build();
Request request = new Request.Builder().url(url)
        .get()
        .build();
Call call = okHttpClient.newCall(request);
call.enqueue(new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {

    }

    @Override
    public void onResponse(Call call, Response response) throws IOException {

    }
});

// RealConnection.java
private void connectSocket(int connectTimeout, int readTimeout, Call call,
    EventListener eventListener) throws IOException {
    Proxy proxy = route.proxy();
    Address address = route.address();

    rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP
      ? address.socketFactory().createSocket()
      : new Socket(proxy);

    eventListener.connectStart(call, route.socketAddress(), proxy);
    rawSocket.setSoTimeout(readTimeout);
    try {
      Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);
    } catch (ConnectException e) {
      ConnectException ce = new ConnectException("Failed to connect to " + route.socketAddress());
      ce.initCause(e);
      throw ce;
    }
  ...
}

最终会走到Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);进行socket连接,此时http代理的connect事件就会响应。

想了解此过程的,可以阅读笔者的另外一篇关于OkHttp实现TCP三次握手的文章:Okhttp是如何管理TCP三次握手四次挥手的

模拟请求出错

结合上述的两种代理场景,就可以得出一个简单的http代理模版了。对于指定的接口,可以返回特定的错误码,在此基础上还能添加一些自定义的条件,譬如2分钟内接口出错,2分钟后恢复正常随机有60%几率出错等。

var http = require('http')
var net = require('net')
var url = require('url')

let startTimestamp = Date.parse(new Date())
function request(cReq, cRes) {
    var u = url.parse(cReq.url)
    var options = {
        hostname : u.hostname, 
        port     : u.port || 80,
        path     : u.path,       
        method     : cReq.method,
        headers     : cReq.headers
    };

    console.log(new Date() + 'request接收到请求:' + cReq.url)

    if (cReq.url == 'http://xxx.com/xxx') {
        cRes.writeHead(503, { 
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Credentials': true,
            'Proxy-Connection': 'keep-alive', 
            'Content-Length': 0, 
            'Content-Type': 'application/json' })
        cRes.end()
    } else {
        var pReq = http.request(options, function(pRes) {
            cRes.writeHead(pRes.statusCode, pRes.headers)
            pRes.pipe(cRes)
        }).on('error', function(e) {
            cRes.end()
        });

        cReq.pipe(pReq)
    }
}

function interceptControl() {
    if (Date.parse(new Date()) - startTimestamp < 2 * 60 * 1000) {
        return true
    } else {
        let random = Math.random()
        console.log('拦截随机数:' + random)
        return random < 0.6
    }
}

function connect(cReq, cSock) {
    var u = url.parse('http://' + cReq.url)
    console.log("connect接收到请求:" + cReq.url)
    var pSock = net.connect(u.port, u.hostname, function() {
        cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
        pSock.pipe(cSock)
    }).on('error', function(e) {
        cSock.end()
    });

    cSock.pipe(pSock)
}

http.createServer()
    .on('request', request)
    .on('connect', connect)
    .listen(8007, '0.0.0.0')
console.info('代理端口8007')    

开发自测中,在手机的wifi网络设置上通过设置代理为pc的ip地址以及端口号(8007),即可与该代理连接。

最后

本文为研究http代理的学习笔记,通过普通代理隧道代理实现了一个简单的http代理。用于定制一些开发自测时的数据mock场景。当然,如果只是简单的接口数据mock,Charles仍然是您的不二之选,毕竟它的Map LocalMap RemoteReWrite等功能也很强大而且代码逻辑健壮。感兴趣的可以看看HTTP 代理原理及实现(二),关于https代理设置,笔者还未实现成功,后续有时间再来研究。

参考文章:

HTTP 代理原理及实现(一)

charles代理原理简析