程序員:單個TCP(Socket)連接,發送多個文件

最近在看一些相關方面的東西,簡單的使用一下 Socket 進行編程是沒有的問題的,但是這樣只是建立了一些基本概念。對於真正的問題,還是無能為力。當我需要進行文件的傳輸時,我發現我好像只是發送過去了數據(二進制數據),但是關於文件的一些信息卻丟失了(文件的擴展名)。而且每次我只能使用一個 Socket 發送一個文件,沒有辦法做到連續發送文件(因為我是依靠關閉流來完成發送文件的,也就是說我其實是不知道文件的長度,所以只能以一個 Socket 連接代表一個文件)。 這些問題困擾了我好久,我去網上簡單的查找了一下,沒有發現什麼現成的例子(可能沒有找到吧),有人提了一下,可以自己定義協議進行發送。 這個倒是激發了我的興趣,感覺像是明白了什麼,因為我剛學過計算機網絡這門課,老實說我學得不怎麼樣,但是計算機網絡的概念我是學習到了。計算機網絡這門課上,提到了很多協議,不知不覺中我也有了協議的概念。所以我找到了解決的辦法:自己在 TCP 層上定義一個簡單的協議。 通過定義協議,這樣問題就迎刃而解了。

協議的作用

從主機1到主機2發送數據,從應用層的角度看,它們只能看到應用程序數據,但是我們通過圖是可以看出來的,數據從主機1開始,每向下一層數據會加上一個首部,然後在網絡上進行傳播,當到達主機2後,每向上一層會去掉一個首部,達到應用層時,就只有數據了。(這裡只是簡單的說明一下,實際上這樣還是不夠嚴謹,但是對於簡單的理解是夠了。)

所以,我可以自己定義一個簡單的協議,將一些必要的信息放在協議頭部,然後讓計算機程序自己解析協議頭部信息,而且每一個協議報文就相當於一個文件。這樣多個協議就是多個文件了。而且協議之間是可以區分的,不然的話,連續傳輸多個文件,如果無法區分屬於每個文件的字節流,那麼傳輸是毫無意義的。

定義數據的發送格式(協議)

這裡的發送格式(我感覺和計算機網絡中的協議有點像,也就稱它為一個簡單的協議吧)。

發送格式:數據頭+數據體

數據頭:一個長度為一字節的數據,表示的內容是文件的類型。

注:因為每個文件的類型是不一樣的,而且長度也不相同,我們知道協議的頭部一般是具有一個固定長度的(對於可變長的那些我們不考慮),所以我採用一個映射關係,即一個字節數字表示一個文件的類型。

舉一個例子,如下:


程序員:單個TCP(Socket)連接,發送多個文件

注:這裡我做的是一個模擬,所以我只要測試幾種就行了。

數據體: 文件的數據部分(二進制數據)。

Talk is cheap, show me your code.

我們來看代碼吧!

客戶端

協議頭部類

package com.dragon;

public class Header {

private byte type; //文件類型

private long length; //文件長度

public Header(byte type, long length) {

super();

this.type = type;

this.length = length;

}

public byte getType() {

return this.type;

}

public long getLength() {

return this.length;

}

}


發送文件類

package com.dragon;

import java.io.BufferedInputStream;

import java.io.BufferedOutputStream;

import java.io.File;

import java.io.FileInputStream;

import java.io.FileNotFoundException;

import java.io.IOException;

import java.net.Socket;

/**

* 模擬文件傳輸協議:

* 協議包含一個頭部和一個數據部分。

* 頭部為 9 字節,其餘為數據部分。

* 規定頭部包含:文件的類型、文件數據的總長度信息。

* */

public class FileTransfer {

private byte[] header = new byte[9]; //協議的頭部為9字節,第一個字節為文件類型,後面8個字節為文件的字節長度。

/**

*@param src source folder

* @throws IOException

* @throws FileNotFoundException

* */

public void transfer(Socket client, String src) throws FileNotFoundException, IOException {

File srcFile = new File(src);

File[] files = srcFile.listFiles(f->f.isFile());

//獲取輸出流

BufferedOutputStream bos = new BufferedOutputStream(client.getOutputStream());

for (File file : files) {

try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file))){

//將文件寫入流中

String filename = file.getName();

System.out.println(filename);

//獲取文件的擴展名

String type = filename.substring(filename.lastIndexOf(".")+1);

long len = file.length();

//使用一個對象來保存文件的類型和長度信息,操作方便。

Header h = new Header(this.getType(type), len);

header = this.getHeader(h);

//將文件基本信息作為頭部寫入流中

bos.write(header, 0, header.length);

//將文件數據作為數據部分寫入流中

int hasRead = 0;

byte[] b = new byte[1024];

while ((hasRead = bis.read(b)) != -1) {

bos.write(b, 0, hasRead);

}

bos.flush(); //強制刷新,否則會出錯!

}

}

}

private byte[] getHeader(Header h) {

byte[] header = new byte[9];

byte t = h.getType();

long v = h.getLength();

header[0] = t; //版本號

header[1] = (byte)(v >>> 56); //長度

header[2] = (byte)(v >>> 48);

header[3] = (byte)(v >>> 40);

header[4] = (byte)(v >>> 32);

header[5] = (byte)(v >>> 24);

header[6] = (byte)(v >>> 16);

header[7] = (byte)(v >>> 8);

header[8] = (byte)(v >>> 0);

return header;

}

/**

* 使用 0-127 作為類型的代號

* */

private byte getType(String type) {

byte t = 0;

switch (type.toLowerCase()) {

case "txt": t = 0; break;

case "png": t=1; break;

case "jpg": t=2; break;

case "jpeg": t=3; break;

case "avi": t=4; break;

}

return t;

}

}


注:

發送完一個文件後需要強制刷新一下。因為我是使用的緩衝流,我們知道為了提高發送的效率,並不是一有數據就發送,而是等待緩衝區滿了以後再發送,因為 IO 過程是很慢的(相較於 CPU),所以如果不刷新的話,當數據量特別小的文件時,可能會導致服務器端接收不到數據(這個問題,感興趣的可以去了解一下。),這是一個需要注意的問題。(我測試的例子有一個文本文件只有31字節)。

getLong() 方法將一個 long 型數據轉為 byte 型數據,我們知道 long 佔8個字節,但是這個方法是我從Java源碼裡面抄過來的,有一個類叫做 DataOutputStream,它有一個方法是 writeLong(),它的底層實現就是將 long 轉為 byte,所以我直接借鑑過來了。(其實,這個也不是很複雜,它只是涉及了位運算,但是寫出來這個代碼就是很厲害了,所以我選擇直接使用這段代碼,如果對於位運算感興趣,可以參考一個我的博客:位運算)。

測試類

package com.dragon;

import java.io.IOException;

import java.net.Socket;

import java.net.UnknownHostException;

//類型使用代號:固定長度

//文件長度:long->byte 固定長度

public class Test {

public static void main(String[] args) throws UnknownHostException, IOException {

FileTransfer fileTransfer = new FileTransfer();

try (Socket client = new Socket("127.0.0.1", 8000)) {

fileTransfer.transfer(client, "D:/DBC/src");

}

}

}


服務器端

協議解析類

package com.dragon;

import java.io.BufferedInputStream;

import java.io.BufferedOutputStream;

import java.io.File;

import java.io.FileNotFoundException;

import java.io.FileOutputStream;

import java.io.IOException;

import java.net.Socket;

import java.util.UUID;

/**

* 接受客戶端傳過來的文件數據,並將其還原為文件。

* */

public class FileResolve {

private byte[] header = new byte[9];

/**

* @param des 輸出文件的目錄

* */

public void fileResolve(Socket client, String des) throws IOException {

BufferedInputStream bis = new BufferedInputStream(client.getInputStream());

File desFile = new File(des);

if (!desFile.exists()) {

if (!desFile.mkdirs()) {

throw new FileNotFoundException("無法創建輸出路徑");

}

}

while (true) {

//先讀取文件的頭部信息

int exit = bis.read(header, 0, header.length);

//當最後一個文件發送完,客戶端會停止,服務器端讀取完數據後,就應該關閉了,

//否則就會造成死循環,並且會批量產生最後一個文件,但是沒有任何數據。

if (exit == -1) {

System.out.println("文件上傳結束!");

break;

}

String type = this.getType(header[0]);

String filename = UUID.randomUUID().toString()+"."+type;

System.out.println(filename);

//獲取文件的長度

long len = this.getLength(header);

long count = 0L;

try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File(des, filename)))){

int hasRead = 0;

byte[] b = new byte[1024];

while (count < len && (hasRead = bis.read(b)) != -1) {

bos.write(b, 0, hasRead);

count += (long)hasRead;

/**

* 當文件最後一部分不足1024時,直接讀取此部分,然後結束。

* 文件已經讀取完成了。

* */

int last = (int)(len-count);

if (last < 1024 && last > 0) {

//這裡不考慮網絡原因造成的無法讀取準確的字節數,暫且認為網絡是正常的。

byte[] lastData = new byte[last];

bis.read(lastData);

bos.write(lastData, 0, last);

count += (long)last;

}

}

}

}

}

/**

* 使用 0-127 作為類型的代號

* */

private String getType(int type) {

String t = "";

switch (type) {

case 0: t = "txt"; break;

case 1: t = "png"; break;

case 2: t = "jpg"; break;

case 3: t = "jpeg"; break;

case 4: t = "avi"; break;

}

return t;

}

private long getLength(byte[] h) {

return (((long)h[1] << 56) +

((long)(h[2] & 255) << 48) +

((long)(h[3] & 255) << 40) +

((long)(h[4] & 255) << 32) +

((long)(h[5] & 255) << 24) +

((h[6] & 255) << 16) +

((h[7] & 255) << 8) +

((h[8] & 255) << 0));

}

}


注:

這個將 byte 轉為 long 的方法,相信大家也能猜出來了。DataInputStream 有一個方法叫 readLong(),所以我直接拿來使用了。(我覺得這兩段代碼寫的非常好,不過我就看了幾個類的源碼,哈哈!)

這裡我使用一個死循環進行文件的讀取,但是我在測試的時候,發現了一個問題很難解決:什麼時候結束循環。 我一開始使用 client 關閉作為退出條件,但是發現無法起作用。後來發現,對於網絡流來說,如果讀取到 -1 說明對面的輸入流已經關閉了,因此使用這個作為退出循環的標誌。如果刪去了這句代碼,程序會無法自動終止,並且會一直產生最後一個讀取的文件,但是由於無法讀取到數據,所以文件都是 0 字節的文件。 (這個東西產生文件的速度很快,大概幾秒鐘就會產生幾千個文件,如果感興趣,可以嘗試一下,但是最好快速終止程序的運行,哈哈!)

if (exit == -1) {

System.out.println("文件上傳結束!");

break;

}


測試類

這裡只測試一個連接就行了,這只是一個說明的例子。

package com.dragon;

import java.io.IOException;

import java.net.ServerSocket;

import java.net.Socket;

public class Test {

public static void main(String[] args) throws IOException {

try (ServerSocket server = new ServerSocket(8000)){

Socket client = server.accept();

FileResolve fileResolve = new FileResolve();

fileResolve.fileResolve(client, "D:/DBC/des");

}

}

}


測試結果

Client

程序員:單個TCP(Socket)連接,發送多個文件

Server

程序員:單個TCP(Socket)連接,發送多個文件

源文件目錄
這裡麵包含了我測試的五種文件。注意對比文件的大小信息,對於IO的測試,我喜歡使用圖片和視頻測試,因為它們是很特殊的文件,如果錯了一點(字節少了、多了),文件基本上就損壞了,表現為圖片不正常顯示,視頻無法正常播放。


程序員:單個TCP(Socket)連接,發送多個文件

目的文件目錄

程序員:單個TCP(Socket)連接,發送多個文件

總結

這個問題應該是解決了,我這裡經過測試,應該是沒有問題的了。我的代碼寫的不是太好,有時候都沒有怎麼思考,想到哪就寫到哪,這樣看來還是有很大問題。這個例子的代碼很簡單,不過我發現了一個很有趣的問題,因為我最近看到了一個手寫 Http 服務器的(使用Java簡單的寫一個。),自己也嘗試了一下(還沒看完)。

我們知道 HTTP 協議,也是具有響應頭和響應體,我覺得我這個和 HTTP 協議有點相似,雖然我的想法很簡陋,但是好像確實是有點相似,可能我看到的東西,對我也有了影響。

從基本思考結束,開始動手寫代碼,然後是調試、測試,接著就是寫博客了,大概也用了幾個小時。我挺喜歡這樣的,一次性完成代碼、博客,當然了這也與這個問題本身對於我來說是很合適有關。經常接觸一些知識看來是很有用的,如果我沒有學過計算機網絡的話,我是不會有這種概念的,那麼對於這個問題我就束手無策了,哈哈!




原文鏈接:https://blog.csdn.net/qq_40734247/article/details/104112142


分享到:


相關文章: