代码仓库信息
仓库地址
https://github.com/monkeyWie/proxyee
星级:940
fork:407
可以看到维护非常及时,最新的提交是2021/2/24。作者在issue中的回复也比较积极。
阅读分支
分支:master
commit:b811b898ed21913fd6be14e048f5ad1a0ab48965
主要功能
proxyee是一个非常“干净”的二方库,总共只有2326行代码,依赖也仅有netty和bcpkix-jdk15on(后者主要是为了支持SSL)。
主要功能是做proxy:
例如,引入该二方库依赖以后,我们在main函数中,写如下代码就可以启动一个proxy server。
publicstaticvoidmain(String[] args) throws Exception {
HttpProxyServerConfig config = new HttpProxyServerConfig();
config.setHandleSsl(true);
new HttpProxyServer()
.serverConfig(config)
.proxyInterceptInitializer(new HttpProxyInterceptInitializer() {
@Override
public void init(HttpProxyInterceptPipeline pipeline) {
pipeline.addLast(new CertDownIntercept());
pipeline.addLast(new FullResponseIntercept() {
@Override
public boolean match(HttpRequest httpRequest, HttpResponse httpResponse, HttpProxyInterceptPipeline pipeline) {
//在匹配到百度首页时插入js
return HttpUtil.checkUrl(pipeline.getHttpRequest(), "^www.baidu.com$")
&& HttpUtil.isHtml(httpRequest, httpResponse);
}
@Override
public void handleResponse(HttpRequest httpRequest, FullHttpResponse httpResponse, HttpProxyInterceptPipeline pipeline) {
//打印原始响应信息
System.out.println(httpResponse.toString());
System.out.println(httpResponse.content().toString(Charset.defaultCharset()));
//修改响应头和响应体
httpResponse.headers().set("handel", "edit head");
/*int index = ByteUtil.findText(httpResponse.content(), "<head>");
ByteUtil.insertText(httpResponse.content(), index, "<script>alert(1)</script>");*/
httpResponse.content().writeBytes("<script>alert('hello proxyee')</script>".getBytes());
}
});
}
})
.start(9999);
}
启动以后,使用如下命令访问百度:
curl -H "accept:text/html" -k -x 127.0.0.1:9999 https://www.baidu.com
能看到百度的返回中包含如下字符:
总结一下:
- 实现proxy server,后面我们能看到使用的是netty。
- 支持定义拦截的逻辑。支持Basic验证。
- 允许定制请求和响应的修改类:“雁过拔毛”或者“雁过加毛”。在发送请求之前,修改请求(例如添加额外的请求header);在收到响应以后,修改响应内容,再返回给客户端。
- 支持SSL。本文对这部分先不做讨论。
如何使用
引入依赖:
<dependency>
<groupId>com.github.monkeywie</groupId>
<artifactId>proxyee</artifactId>
<version>1.4.1</version>
</dependency>
参考上面的代码就可以使用了。
准备知识
任何一个web proxy,本质上是扮演了两个角色:
- http服务器,用于接收请求。
- http客户端,用于把接收的请求代替别人发送出去。
启动服务器,接收请求,并帮助把请求发送出去,这些功能的实现,proxyee都依赖于netty。
所以我们先科普一下netty是如何创建一个TCP服务器和发送TCP请求的,熟悉netty有助于帮忙我们理解proxyee本身的代码。
netty是一个NIO的异步网络编程框架,很多RPC服务(例如我们的HSF)都是使用的netty。
Netty TCP Server
使用netty创建TCP Server的代码长这个样子:
// 1.
EventLoopGroup group = new NioEventLoopGroup();
try{
// 2.
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(group);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.localAddress(new InetSocketAddress("localhost", 9999));
// 3.
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new HelloServerHandler());
}
});
// 4.
ChannelFuture channelFuture = serverBootstrap.bind().sync();
channelFuture.channel().closeFuture().sync();
} catch(Exception e){
e.printStackTrace();
} finally {
group.shutdownGracefully().sync();
}
这上面的代码主要包含如下4个步骤:
- 创建一个EventLoopGroup。
EventLoopGroup group = new NioEventLoopGroup();
我们可以这样理解EventLoopGroup:里面包含了一组线程,每一个线程都在不停地等待和处理IO事件,也就是接受TCP连接请求,并处理它们。Netty向我们保证,从一个TCP请求创建,到服务请求完成返回响应,都来自该线程组中某一个特定的线程。
- 创建一个ServerBootstrap。
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(group);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.localAddress(new InetSocketAddress("localhost", 9999));
bootstrap代表一个服务器实例,用于管理线程,绑定socket连接等。
首先,我们使用上面创建的EventLoopGroup来初始化它。
然后,设置服务器的channel为NioServerSocketChannel。Channel是netty的概念。当客户端通过TCP连接成功连接到服务器以后,一个Channel就会被创建,这里我们使用的Channel类型是NioServerSocketChannel,因为我们要用Java NIO来接受和响应请求。
最后,我们设置服务器的地址(localhost)和监听端口(9999)。
- 创建一个ChannelInitializer。
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new HelloServerHandler());
}
});
创建ChannelInitializer并设置到ServerBootstrap上。ChannelInitializer主要工作是每当有新的TCP连接来的时 候,其initChannel方法都会被调用。通常来说,我们会在这里指定我们的请求处理器(Handler),例如我们该怎么解析消息体,如何返回响应等。
我们通过把这个handler加入到channel的pipeline里面来发挥它的作用。
channel的pipline,可以理解成Servlet里面的FilterChain。一个请求被读取以后,或者一个响应在发送之前,加入到pipline中的多个handler会对请求或者响应发生作用。
通常来说有两类handler:
- ChannelInboundHandler:专门处理来自客户端的请求,例如如果是一个http请求,可能你希望把他反序列化,拿到http的header,body等。
- ChannelOutboundHandler:专门处理要发送给客户端的响应。
BTW,下面这个注释画的不错:
* I/O Request
* via {@link Channel} or
* {@link ChannelHandlerContext}
* |
* +---------------------------------------------------+---------------+
* | ChannelPipeline | |
* | \|/ |
* | +---------------------+ +-----------+----------+ |
* | | Inbound Handler N | | Outbound Handler 1 | |
* | +----------+----------+ +-----------+----------+ |
* | /|\ | |
* | | \|/ |
* | +----------+----------+ +-----------+----------+ |
* | | Inbound Handler N-1 | | Outbound Handler 2 | |
* | +----------+----------+ +-----------+----------+ |
* | /|\ . |
* | . . |
* | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
* | [ method call] [method call] |
* | . . |
* | . \|/ |
* | +----------+----------+ +-----------+----------+ |
* | | Inbound Handler 2 | | Outbound Handler M-1 | |
* | +----------+----------+ +-----------+----------+ |
* | /|\ | |
* | | \|/ |
* | +----------+----------+ +-----------+----------+ |
* | | Inbound Handler 1 | | Outbound Handler M | |
* | +----------+----------+ +-----------+----------+ |
* | /|\ | |
* +---------------+-----------------------------------+---------------+
* | \|/
* +---------------+-----------------------------------+---------------+
* | | | |
* | [ Socket.read() ] [ Socket.write() ] |
* | |
* | Netty Internal I/O Threads (Transport Implementation) |
* +-------------------------------------------------------------------+
出处:io.netty.channel.ChannelPipeline.java
- 启动服务。
ChannelFuture channelFuture = serverBootstrap.bind().sync();
最后一步,启动TCP服务。sync方法阻塞当前线程,直到服务完全启动。返回的对象是对Channel的一个封装,我们可以在该对象上添加各种服务级别事件的监听。
Netty TCP Client
我们也可以使用netty发送TCP请求。下面这段代码启动了一个TCP客户端。
// 1.
EventLoopGroup group = new NioEventLoopGroup();
try{
// 2.
Bootstrap clientBootstrap = new Bootstrap();
clientBootstrap.group(group);
clientBootstrap.channel(NioSocketChannel.class);
clientBootstrap.remoteAddress(new InetSocketAddress("localhost", 9999));
// 3.
clientBootstrap.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new ClientHandler());
}
});
// 4.
ChannelFuture channelFuture = clientBootstrap.connect().sync();
channelFuture.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
上面的代码也主要分为4步。
- 创建一个EventLoopGroup。
EventLoopGroup group = new NioEventLoopGroup();
这个和服务端创建是一样的。
- 创建和配置一个Bootstrap。
Bootstrap clientBootstrap = new Bootstrap();
第二步我们创建一个TCP的客户端。服务端使用的是ServerBootstrap。这里我们使用的是Bootstrap。同时我们设置事件循环组,Channel类型和远端服务器地址。
clientBootstrap.group(group);
clientBootstrap.channel(NioSocketChannel.class);
clientBootstrap.remoteAddress(new InetSocketAddress("localhost", 9999));
- 创建一个ChannelInitializer。
clientBootstrap.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new ClientHandler());
}
});
ChannelInitializer在每次TCP连接建立的时候,其initChannel都会被调用。上面的代码中,当发起TCP请求的时候,我们添加一个ClientHandler用于处理TCP请求的发送。
同时我们将ChannelInitializer设置为客户端clientBootstrap的handler。
4. 启动客户端
最后一步,我们的客户端需要连接远端服务器,
ChannelFuture channelFuture = clientBootstrap.connect().sync();
该行代码同样返回一个ChannelFuture。
然后我们可以关闭client。
channelFuture.channel().closeFuture().sync();
代码阅读
源码结构
proxyee是一个单module的maven项目,test目录并未包含单测,而是每个class都包含一个可运行的main方法,用于演示如何实现各种proxy的功能。
main目录是这样的:
其中handler目录下,
- HttpProxyServerHandler是一个ChannelInboundHandlerAdapter(即ChannelInboundHandler),实现了proxy服务端的功能。
- HttpProxyClientHandler也是一个ChannelInboundHandler,实现了proxy作为客户端的功能。
这两个是proxyee的核心类。
服务启动
下面是proxyee启动服务的代码。
new HttpProxyServer()
.serverConfig(config)
.proxyInterceptInitializer(new HttpProxyInterceptInitializer() {
@Override
public void init(HttpProxyInterceptPipeline pipeline) {
pipeline.addLast(new CertDownIntercept());
pipeline.addLast(new FullResponseIntercept() {
@Override
public void handleResponse(HttpRequest httpRequest, FullHttpResponse httpResponse, HttpProxyInterceptPipeline pipeline) {
ByteUtil.insertText(httpResponse.content(), index, "<script>alert(1)</script>");*/
httpResponse.content().writeBytes("<script>alert('hello proxyee')</script>".getBytes());
}
});
}
}).start(9999);
很类似netty服务端启动时添加handler的方式:
// 3. netty服务端添加IO handler
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new HelloServerHandler());
}
});
我们可以看到在proxyee源码中,作者参考了netty的SDK设计的风格。
在proxyee中HttpProxyInterceptInitializer类似netty中ChannelInitializer的作用,在每次请求建立的时候,HttpProxyInterceptInitializer的init(HttpProxyInterceptPipeline pipeline)方法都会被调用,用户可以在此方法中趁机添加IO处理的类。
我们从HttpProxyServer的start方法进入,看看启动一个proxy server的流程。
publicvoidstart(String ip, int port) {
// 1.
init();
// 2.
bossGroup = new NioEventLoopGroup(serverConfig.getBossGroupThreads());
workerGroup = new NioEventLoopGroup(serverConfig.getWorkerGroupThreads());
try {
// 3.
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
// 4.
.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) throws Exception {
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
ch.pipeline().addLast("serverHandle",
new HttpProxyServerHandler(serverConfig, proxyInterceptInitializer, tunnelIntercept, proxyConfig,
httpProxyExceptionHandle));
}
});
ChannelFuture f;
if (ip == null) {
f = b.bind(port).sync();
} else {
f = b.bind(ip, port).sync();
}
f.channel().closeFuture().sync();
} catch (Exception e) {
httpProxyExceptionHandle.startCatch(e);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
- init()。
主要是初始化和SSL证书相关的操作。
- 创建父子事件循环组。
bossGroup = new NioEventLoopGroup(serverConfig.getBossGroupThreads());
workerGroup = new NioEventLoopGroup(serverConfig.getWorkerGroupThreads());
在Netty服务端启动时候,我们可以使用父子事件循环组,其中bossGroup专门用于监听客户端的连接事件,连接成功以后,workerGroup则会负责连接中发生的IO事件。
- Netty服务端初始化。
这部分几乎没有什么可以说的,完全是Netty标准做法。忘记了的同学可以回去参考一下“Netty TCP Server”那一节。
- childHandler的设置。
值得注意的是他添加的自定义Netty IO handler。
.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) throws Exception {
// 4.1
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
// 4.2
ch.pipeline().addLast("serverHandle",
new HttpProxyServerHandler(serverConfig, proxyInterceptInitializer, tunnelIntercept, proxyConfig,
httpProxyExceptionHandle));
}
});
首先,添加了一个httpCodec的handler。这是Netty自带的提供http服务的handler。它是一个“全双工”handler:既是一个InHandler,也是一个outHandler,负责把接收到的二进制请求反序列化成Http Request对象,并序列化Http Response。
然后,添加了一个“serverHandler”,类型是HttpProxyServerHandler,它也继承了Netty的ChannelInboundHandlerAdapter,所以它是一个处理IO请求的“拦截器”。
然后启动流程就做完了,是不是非常简单?
接下来,我们看一下在HttpProxyServerHandler中,proxyee做了什么操作。其实我们可以猜测一下,前面说到一个proxyee是需要扮演服务器和客户端的角色的,那么HttpProxyServerHandler里面的主要逻辑应该包括两个:
- 接受来自客户的请求。
- 代替客户发送请求,收到响应以后再返回给客户。
服务端请求的接受
HttpProxyServerHandler继承ChannelInboundHandlerAdapter以后,主要处理http请求的逻辑在方法channelRead中体现。
这里首先要插一句:HttpProxyServerHandler的生命周期是什么样的?
我们可以在HttpProxyServerHandler构造函数中断点,然后发送curl请求。发现每次发送请求都会新构造一个该类的实例。考虑Netty事件循环的特点,所以该类的实例变量是线程安全的。
回到channelRead方法。
@Override
publicvoidchannelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
if (msg instanceof HttpRequest) {
// ...
interceptPipeline = buildPipeline();
interceptPipeline.setRequestProto(new RequestProto(host, port, isSsl));
// ...
interceptPipeline.beforeRequest(ctx.channel(), request);
} else if (msg instanceof HttpContent) {
// ...
interceptPipeline.beforeRequest(ctx.channel(), (HttpContent) msg);
}
// ...
}
这个方法主要在做两件事。
- 如果它接收到的msg对象是一个HttpRequest类型,那么初始化proxyee的pipeline,同时依次调用pipeline中定义的各拦截器的beforeRequest(HttpRequest msg)方法。
- HttpRequest是Netty暴露的一个类,包含Http请求的请求头。
- 否则,如果接收到的msg对象是一个HttpContent类型,那么依次调用pipeline中定义的各拦截器的beforeRequest(HttpContent msg)。
- HttpContent是Netty暴露的一个类,包含Http请求的请求体。
- 当2发生以后,channelRead收集到客户端的请求头和请求体了,这时候代替客户,发送请求到目标服务器。
注意到,1和2是互斥的两个操作,也就是说channelRead根据入参msg(类型是一个Object),要么做1,要么做2。所以我们知道channelRead一定是会被调用两次的,一次传入请求头,一次传入请求体。
小知识
Netty的这个行为和HttpServerCodec的实现有关。在其父类ByteToMessageDecoder中,解码Http请求以后,channelRead方法会得到请求头和请求体两个msg实体,然后对两个实体分别触发Netty pipeline下游的其他IO handler。
参考:io.netty.handler.codec.ByteToMessageDecoder 322行
static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
for (int i = 0; i < numElements; i ++) {
ctx.fireChannelRead(msgs.getUnsafe(i));
}
}
我们前面说了,Netty本身在IO请求过程中,也是使用的pipeline的模式(敲黑板:设计模式之责任链)。proxyee显然也参考了该设计模式,构建了一个pipeline来允许proxyee用户自定义拦截器。
到此为止,我们知道proxyee作为服务器角色的逻辑,但是我们还不清楚它是如何扮演客户端角色,帮助转发请求的。
请求的流动
我们看一下在proxyee的pipeline中都“build”进去哪些拦截器。
interceptPipeline = buildPipeline();
interceptPipeline的类型HttpProxyInterceptPipeline,实现了Iterable接口,用于遍历HttpProxyIntercept。每一个HttpProxyIntercept有如下方法:
/**
* 拦截代理服务器到目标服务器的请求头
*/
public void beforeRequest(Channel clientChannel, HttpRequest httpRequest,
HttpProxyInterceptPipeline pipeline);
/**
* 拦截代理服务器到目标服务器的请求体
*/
public void beforeRequest(Channel clientChannel, HttpContent httpContent,
HttpProxyInterceptPipeline pipeline);
/**
* 拦截代理服务器到客户端的响应头
*/
public void afterResponse(Channel clientChannel, Channel proxyChannel, HttpResponse httpResponse,
HttpProxyInterceptPipeline pipeline);
/**
* 拦截代理服务器到客户端的响应体
*/
public void afterResponse(Channel clientChannel, Channel proxyChannel, HttpContent httpContent,
HttpProxyInterceptPipeline pipeline);
在构建proxyee pipeline代码中,proxyee首先加入了一个默认处理器。
new HttpProxyIntercept() {
@Override
public void beforeRequest(Channel clientChannel, HttpRequest httpRequest, HttpProxyInterceptPipeline pipeline)
throws Exception {
handleProxyData(clientChannel, httpRequest, true);
}
@Override
public void beforeRequest(Channel clientChannel, HttpContent httpContent, HttpProxyInterceptPipeline pipeline)
throws Exception {
handleProxyData(clientChannel, httpContent, true);
}
@Override
public void afterResponse(Channel clientChannel, Channel proxyChannel, HttpResponse httpResponse,
HttpProxyInterceptPipeline pipeline) throws Exception {
clientChannel.writeAndFlush(httpResponse);
if (HttpHeaderValues.WEBSOCKET.toString().equals(httpResponse.headers().get(HttpHeaderNames.UPGRADE))) {
// websocket转发原始报文
proxyChannel.pipeline().remove("httpCodec");
clientChannel.pipeline().remove("httpCodec");
}
}
@Override
public void afterResponse(Channel clientChannel, Channel proxyChannel, HttpContent httpContent,
HttpProxyInterceptPipeline pipeline) throws Exception {
clientChannel.writeAndFlush(httpContent);
}
}
这个默认拦截器始终放在HttpProxyInterceptPipeline的队尾,其他用户自定义的拦截器前置于它。
当在channelRead方法中调用:
interceptPipeline.beforeRequest(ctx.channel(), request);
的时候,interceptPipeline会以递归的方式,依次调用其添加拦截器,直到遍历到最后一个,也即如上的默认拦截器。
publicvoidbeforeRequest(Channel clientChannel, HttpRequest httpRequest) throws Exception {
this.httpRequest = httpRequest;
if (this.posBeforeHead < intercepts.size()) {
HttpProxyIntercept intercept = intercepts.get(this.posBeforeHead++);
intercept.beforeRequest(clientChannel, this.httpRequest, this);
}
this.posBeforeHead = 0;
}
递归的部分比较巧妙,
intercept.beforeRequest(clientChannel, this.httpRequest, this);
会在执行完自己的逻辑以后,再次调用interceptPipeline(通过第三个参数this传递)的beforeRequest,从而继续进入下一个intercept的调用。这个和Netty的fireChannelEvent类似,也类似Servlet中的FilterChain。
我们关注最后一个默认的intercept,其中处理request的部分的handleProxyData方法。
publicvoidbeforeRequest(Channel clientChannel, HttpRequest httpRequest, HttpProxyInterceptPipeline pipeline)
throws Exception {
handleProxyData(clientChannel, httpRequest, true);
}
方法实现:
privatevoidhandleProxyData(Channel channel, Object msg, boolean isHttp) throws Exception {
if (cf == null) {
// ...
requestProto = interceptPipeline.getRequestProto();
HttpRequest httpRequest = (HttpRequest) msg;
//..
ChannelInitializer channelInitializer = isHttp ? new HttpProxyInitializer(channel, requestProto, proxyHandler)
: new TunnelProxyInitializer(channel, proxyHandler);
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(serverConfig.getProxyLoopGroup()) // 注册线程池
.channel(NioSocketChannel.class) // 使用NioSocketChannel来作为连接用的channel类
.handler(channelInitializer);
if (proxyHandler != null) {
// 代理服务器解析DNS和连接
bootstrap.resolver(NoopAddressResolverGroup.INSTANCE);
} else {
bootstrap.resolver(serverConfig.resolver());
}
requestList = new LinkedList();
cf = bootstrap.connect(requestProto.getHost(), requestProto.getPort());
cf.addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
future.channel().writeAndFlush(msg);
synchronized (requestList) {
requestList.forEach(obj -> future.channel().writeAndFlush(obj));
requestList.clear();
isConnect = true;
}
} else {
requestList.forEach(obj -> ReferenceCountUtil.release(obj));
requestList.clear();
getExceptionHandle().beforeCatch(channel, future.cause());
future.channel().close();
channel.close();
}
});
} else {
synchronized (requestList) {
if (isConnect) {
cf.channel().writeAndFlush(msg);
} else {
requestList.add(msg);
}
}
}
}
handleProxyData也主要做了两件事:
- 如果是首次调用,创建并初始化转发请求使用channle,连接远端目标服务。流程和“Netty TCP Client”描述的一样。
- 如果不是首次调用,把请求的对象加入到requestList列表中。待连接恢复,依次发送列表中暂存的请求对象。
注意由于在1中,连接成功发送请求并清空requestList,并不是在当前线程(future listener),所以requestList的修改需要同步锁。
到此为止,我们cover了proxyee的主要流程。总结一下:
作者真是玩套娃的好手。
总结
proxyee是一款基于Netty的开源代理二方库,代码精简,易于学习,同时也能帮助大家理解Netty的SDK行为。
参考文档