基于IO模型实现一个tcp端口的代理
背景
最近研究了io通信模型,但是没有经历过实践的学习其实是没有意义的,所以希望能通过实现一些实用的东西来发现学习中的不足。关于日常的端口代理我们常见的就是nginx可以代理http端口,当然也可以代理tcp端口,不过代理tcp端口是需要安装插件的,对于一些管理比较严格的公司软件是不允许自己安装的,对于插件的安装也有要求,虽然软件拥有功能但是并不一定允许使用,如果一味的把希望寄托在软件上不是一个明智的选择,所以选择了使用io通信实现一个tcp端口的代理工具。
原理
说到代理的原理其实是比较容易理解的,简单的说就是把一个连接的输入流中内容读取出来再写到另外一个连接中去就可以了,不过理论和实践是两回事,说起来容易做起来难啊,需要考虑性能问题,cpu占用率,内存使用率,线程数,连接数,并发量等情况还是需要深入研究的,建议大家有兴趣可以自己先写一下试试会遇到些什么问题,关于io通信的模型的基础讲解网上太多建议大家自行查看,由于常见的博客太多,大多数都是重复内容,废话也比较多,所以直接给大家上两段代码。
bio通信方式
代码
package com.yx.bio;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author yx
* @date 2020/9/12 14:03
* Description 本类为tcp代理工具类BIO版本,可以直接用本机的某个端口代理他可以访问到的任意一台主机的任意端口,因为连接会自动超时,
* 程序会抛出异常属于正常现象,再次请求会 自动连接,本次测试以代理mysql端口和sftp端口为例
*/
@SuppressWarnings("all")
public class BIOProxy extends Thread {
private Socket readSocket;
private Socket writeSocket;
public BIOProxy(Socket readSocket, Socket writeSocket) {
System.out.println("init .....");
this.readSocket = readSocket;
this.writeSocket = writeSocket;
}
public static void main(String[] args) throws IOException {
final int listenPort = 9999;
ServerSocket serverSocket = new ServerSocket(listenPort);
final ExecutorService execute = Executors.newCachedThreadPool();
System.out.println("服务启动在端口:" + listenPort);
String remoteHost = "127.0.0.1";
int port = 3306;
System.out.println("代理为本地端口:" + listenPort + "代理" + remoteHost + "地址的" + port + "端口");
while (true) {
//防止主线程出错程序终止
Socket cliSocket = null;
Socket serSocket = null;
try {
cliSocket = serverSocket.accept();
serSocket = new Socket(remoteHost, port);
cliSocket.setKeepAlive(true);
serSocket.setKeepAlive(true);
//加入任务列表,等待处理
execute.execute(new BIOProxy(cliSocket, serSocket));
execute.execute(new BIOProxy(serSocket, cliSocket));
} catch (Exception e) {
try {
serSocket.close();
} catch (Exception e1) {
}
try {
cliSocket.close();
} catch (Exception e1) {
}
e.printStackTrace();
}
}
}
@Override
public void run() {
//不要问为什么不写关闭流的方法,建议去看一下 Java try-with-resource 的语法
try (InputStream inputStream = readSocket.getInputStream();
OutputStream outputStream = writeSocket.getOutputStream();) {
byte[] datas = new byte[1024];
int len;
while ((len = inputStream.read(datas)) != -1) {
if (len > 0) {
outputStream.write(datas, 0, len);
outputStream.flush();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
说明
基于bio模型这段代理代码不足一百行,也是比较容易理解的,适合刚接触的小伙伴们,不过毕竟bio模型的缺点是显而易见的,每次一个连接需要创建两个线程去处理,如果连接数量大的话肯定是不适用的,建议大家看看理解下原理就好,下面给大家看一下基于nio模型的通信方式,在性能上还是比较不错的。
nio通信方式
代码
package com.yx.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
/**
* @author yx
* @date 2020/9/12 18:55
* Description 本类为tcp代理工具类BIO版本,可以直接用本机的某个端口代理他可以访问到的任意一台主机的任意端口,因为连接会自动超时,
* * 程序会抛出异常属于正常现象,再次请求会 自动连接,本次测试以代理mysql端口和sftp端口为例
*/
@SuppressWarnings("all")
public class NIOProxy extends Thread {
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
//用于监听key的请求
Selector selector;
InetSocketAddress remote;
public static void main(String[] args) throws IOException {
String remoteAddr = "192.168.10.11";
int remotePort = 22;
int localPort = 11200;
new NIOProxy(localPort, remoteAddr, remotePort).start();
}
@Override
public void run() {
while (true) {
try {
selector.select();
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isValid()) {
if (key.isAcceptable()) {
Acceptable(key);
}
//在这个分类中不会涉及到所谓的写key的方法,所以注销了
// if (key.isWritable()) {
// WriteData(key);
// }
if (key.isReadable()) {
ReadData(key);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
public NIOProxy(int localPort, String remoteAddr, int remotePort) {
System.out.println("代理为本地端口:" + localPort + "代理" + remoteAddr + "地址的" + remotePort + "端口");
try {
selector = Selector.open();
remote = new InetSocketAddress(remoteAddr, remotePort);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
SocketAddress sockerAddress = new InetSocketAddress(localPort);
serverSocketChannel.bind(sockerAddress);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
private void Acceptable(SelectionKey key) {
try {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
//用于监听服务端的连接请求的key
SocketChannel clientChannel = serverSocketChannel.accept();
clientChannel.configureBlocking(false);
SocketChannel serverChannel = SocketChannel.open();
serverChannel.configureBlocking(false);
// 连接远程服务器。
serverChannel.connect(remote);
//这一句不能少,因为请求了连接如果要注入到select中去读取数据需要手动完成连接
serverChannel.finishConnect();
clientChannel.register(selector, SelectionKey.OP_READ, serverChannel);
serverChannel.register(selector, SelectionKey.OP_READ, clientChannel);
} catch (Exception e) {
System.out.println("连接异常");
}
}
/**
* 不要考虑所谓的读写分方法了,转发的原理就是直接从一个通道里获取输入流然后直接写入到另外一个通道就行了,没必要分方法
*
* @param key
*/
private void ReadData(SelectionKey key) throws IOException {
SocketChannel otherChannel = (SocketChannel) key.attachment();
try {
SocketChannel socketChannel = (SocketChannel) key.channel();
//因为存在连接可能还没有来得及连接成功就运行到这里所以为了处理报错手动判断一次是否连接成功,判断是否完成连接,
// 如果没有完成就重新发起 连接请求,直到连接成功才进行下一步,这两个循环的代码主要是为了处理http请求和https请求的,
// 因为都是无状态协议,而且http请求是长连接,不能确定客户端与服务端什么时候会真正断开并且释放连接(有时候断开了服务端也不一定会立即释放连接),
// 如果查询到客户端断开了捕获到异常会把服务端也一块断开,不过这么处理的话http的请求有可能会长期无法释放占用资源,不是一个好的处理方式,
// 而且http请求比较频繁,每次发送一个次数据都会重新建立连接发送请求,会把大量资源用于创建连接上,基于tcp端口的代理应该把更多的资源用于数据转发上,
// 因此不建议大家用来代理无状态的http协议,具体怎么使用请大家自行斟酌修改,
// 关于重试连接的次数以及重试的间隔根据自己的环境及网络延迟调整
int resetSocket = 0;
while (true) {
if (socketChannel.isConnected() || resetSocket > 10) {
break;
}
System.out.println("请求完成连接");
socketChannel.finishConnect();
Thread.sleep(10);
}
int reset = 0;
while (true) {
if (otherChannel.isConnected() || reset > 10) {
break;
}
System.out.println("请求完成连接");
otherChannel.finishConnect();
Thread.sleep(10);
}
readBuffer.clear();
int read = socketChannel.read(readBuffer);
if (read == -1) {
key.channel().close();
key.cancel();
otherChannel.close();
return;
}
readBuffer.flip();
// byte[] datas = new byte[readBuffer.remaining()];
// readBuffer.get(datas);
// System.out.println("收到消息:" + new String(datas));
// readBuffer.clear();
// readBuffer.put(datas);
// readBuffer.flip();
otherChannel.write(readBuffer);
// 当读取完数据后重新注册一个读取的key到selector中继续等待读操作。
socketChannel.register(selector, SelectionKey.OP_READ, otherChannel);
} catch (Exception e) {
key.channel().close();
key.cancel();
otherChannel.close();
e.printStackTrace();
}
}
}
说明
如果使用nio模型来做这个代码稍微复杂一点,大概也是一百行左右。不过性能上是有本质区别的,核心就是在selector中注册两个key,一个负责处理客户端请求,一个处理服务端请求,基本原理网上说的太多建议大家自己查看,这里只给大家提供一个思路,也给小伙伴们留一点思考的空间,毕竟只有思考了才会有记忆。
注意:
请仔细阅读ReadData方法中的注释并调整参数,本人不建议用来代理http等无状态协议,如有需要请慎用。
总结
因为网上博客重复内容太多,关于一个知识点的都是大同小异,只有碰到网上找不到的本人才会写成博客,一方面是可以加深自己理解以后查找方便另一方面也是希望能帮助到跟我一样正在学习中的小伙伴们,当然还有更加复杂的可以用aio模型或是使用netty模型来处理,不过原理其实都是在nio模型的基础之上进行封装优化的,原理的区别不大,因为考虑到后期可能会在线上使用不方便贴出来,留给大家自己思考吧,如果是要需要放到线上使用的话肯定还需要做配置文件读取和一个工程代理多个端口映射关系的操作,这部分也留给大家自己去完成吧,希望对大家有帮助。