最近碰到了這樣的需求:用戶通過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.0</modelVersion>
<groupId>zhangchao</groupId>
<artifactId>blog119</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>10</java.version>
<maven.compiler.source>10</maven.compiler.source>
<maven.compiler.target>10</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>blog119.Main</mainClass> <!-- 你的主類名 -->
</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 服務器。