【119】用Java實現TCP協議的IP地址和埠號的轉發功能
最近碰到了這樣的需求:使用者通過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 伺服器。