最近碰到了这样的需求:用户通过TCP访问服务器 A,服务器 A 再把 TCP 请求转发给服务器 B;同时服务器 A 把服务器 B 返回的数据,转发给用户。也就是服务器 A 作为中转站,在用户和服务器 B 之间转发数据。示意图如下:
为了满足这个需求,我用Java开发了程序。我为了备忘,把代码简化了一下,剔除了实际项目中的业务代码,给了一个简单的例子。
这个例子项目名字是 blog119,用 maven 管理、Java 10 编译。整个项目只有一个包:blog119。包下有三个类:CheckRunnable、Main、和 ReadWriteRunnable 。项目中还有一个 maven 项目必有的 pom.xml 文件。接下来是三个文件的内容。
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<groupId>zhangchaogroupId>
<artifactId>blog119artifactId>
<version>0.0.1-SNAPSHOTversion>
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8project.reporting.outputEncoding>
<java.version>10java.version>
<maven.compiler.source>10maven.compiler.source>
<maven.compiler.target>10maven.compiler.target>
properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-jar-pluginartifactId>
<configuration>
<archive>
<manifest>
<mainClass>blog119.MainmainClass>
manifest>
archive>
configuration>
plugin>
plugins>
build>
project>
Main 类,包含 main 方法,调用 CheckRunnable 类和 ReadWriteRunnable 类。
package blog119;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 主类。
* @author 张超
*
*/
public class Main {
/**
* 当前服务器ServerSocket的最大连接数
*/
private static final int MAX_CONNECTION_NUM = 50;
public static void main(String[] args) {
// 启动一个新线程。检查是否要种植程序。
new Thread(new CheckRunnable()).start();
// 当前服务器的IP地址和端口号。
String thisIp = args[0];
int thisPort = Integer.parseInt(args[1]);
// 转出去的目标服务器IP地址和端口号。
String outIp = args[2];
int outPort = Integer.parseInt(args[3]);
ServerSocket ss = null;
try {
ss = new ServerSocket(thisPort, MAX_CONNECTION_NUM, InetAddress.getByName(thisIp));
while(true){
// 用户连接到当前服务器的socket
Socket s = ss.accept();
// 当前服务器连接到目的地服务器的socket。
Socket client = new Socket(outIp, outPort);
// 读取用户发来的流,然后转发到目的地服务器。
new Thread(new ReadWriteRunnable(s, client)).start();
// 读取目的地服务器的发过来的流,然后转发给用户。
new Thread(new ReadWriteRunnable(client, s)).start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (null != ss) {
ss.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
CheckRunnable 类。启动程序的时候创建 running.txt 文件,然后每隔一段时间检测 running.txt 文件是否存在。如果检测到 running.txt 不存在,就终止整个程序。我希望用这种方式来避免粗暴地杀死进程。个别情况下粗暴地杀死进程可能会出问题。
package blog119;
import java.io.File;
import java.io.IOException;
/**
* 新启动一个线程,每隔一段时间就检查一下是否存在 running.txt文件。如果存在,程序正常运行。
* 如果不存在,系统退出。
* @author 张超
*
*/
public class CheckRunnable implements Runnable {
/**
* 取得Java程序当前目录下的running.txt硬盘地址。如果是编译后的jar包,那么
* running.txt 就在jar包所在的文件夹。如果是开发阶段,就在 class 文件目录里面
* @return 取得 running.txt 路径的 File。
*/
private File getFile() {
String path = this.getClass().getProtectionDomain().getCodeSource().getLocation().getFile();
File runningFile = null;
if (path.endsWith(".jar")) {
File tmp = new File(path);
tmp = tmp.getParentFile();
runningFile = new File(tmp.getAbsolutePath() + File.separator + "running.txt");
} else {
runningFile = new File(path + "running.txt");
}
return runningFile;
}
/**
* 构造方法
*/
public CheckRunnable(){
File file = this.getFile();
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void run() {
try {
while (true) {
Thread.sleep(30L * 1000L);
// 没有 running.txt 就退出
File file = this.getFile();
if (!file.exists()) {
System.exit(0);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
ReadWriteRunnable 类。创建对象的时候接受两个 Socket 作为成员变量。从一个 Socket 中读取数据,然后发送到另一个 Socket。
package blog119;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
/**
* 读写流的Runnable
* @author 张超
*
*/
public class ReadWriteRunnable implements Runnable {
/**
* 读入流的数据的套接字。
*/
private Socket readSocket;
/**
* 输出数据的套接字。
*/
private Socket writeSocket;
/**
* 两个套接字参数分别用来读数据和写数据。这个方法仅仅保存套接字的引用,
* 在运行线程的时候会用到。
* @param readSocket 读取数据的套接字。
* @param writeSocket 输出数据的套接字。
*/
public ReadWriteRunnable(Socket readSocket, Socket writeSocket) {
this.readSocket = readSocket;
this.writeSocket = writeSocket;
}
@Override
public void run() {
byte[] b = new byte[1024];
InputStream is = null;
OutputStream os = null;
try {
is = readSocket.getInputStream();
os = writeSocket.getOutputStream();
while(!readSocket.isClosed() && !writeSocket.isClosed()){
int size = is.read(b);
if (size > -1) {
os.write(b, 0, size);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (is != null) {
is.close();
}
if (null != os) {
os.flush();
os.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
在命令行执行这个程序的时候,需要输入四个参数。分别是当前服务器IP地址、当前服务器端口、目的地服务器IP地址、目的地服务器端口。
Eclipse 调试的时候,可鼠标移动到 Main.java 上,右键 → Run As → Run Configurations…
弹出的对话框如下所示:
注意左侧菜单栏选中 Java Application → Main。右侧选项卡选中 Arguments,然后在 Program arguments 中填写参数就行了。
怎么验证项目管用?
我自己建立了一个 Ubuntu 服务器,IP地址是10.30.1.106,开放 SSH 远程登录权限。SSH 默认使用 TCP 协议的 22 号端口。我就用 blog119 做TCP转发,在本地监听 65010 端口。这样,整个映射关系是: 127.0.0.1:65010 对应 10.30.1.106:22
如上图所示,参数是:127.0.0.1 65010 10.30.1.106 22
打开 putty 远程连接工具,IP地址设置成 127.0.0.1,端口是 65010,你会发现可以连接,而且所有命令都能执行,就像直接远程连接 Ubuntu 服务器一样。
为什么本地IP地址还要作为参数进行设置?默认127.0.0.1 不好吗?
我主要考虑到一个服务器可以对应多个 IP 地址的情况。有些时候,你不想在同一台服务器的所有IP地址上都监听同一个端口。所以这里把本地地址作为参数,方便灵活配置。
jar包的用法
eclipse 选中项目右键 → Run As → Maven build … → Main选项卡 → Goals 文本框中输入 clean package 点击 Run 按钮, 就可以打成jar包。直接在命令行中输入
java -jar blog119-0.0.1-SNAPSHOT.jar 127.0.0.1 65111 10.30.1.106 22
程序启动后,会在jar包的文件夹下生成一个 running.txt 文件。如果要关闭程序,删除这个文件,半分钟后程序就会自动关闭。当程序启动的时候,你可以用 putty 访问 127.0.0.1 地址的 65111 端口,就可以事实上远程控制 10.30.1.106 服务器。