Android HTTP編程基礎

HTTP協議是一種請求響應式通信協議,通常是客戶端向服務器端發送資源請求,服務器接收到客戶端請求後返回對應資源響應,兩端不斷重複請求響應的過程就完成了客戶端與服務器端的會話操作。HTTP是運行與TCP協議之上的應用層協議,它定義自己獨特的報文格式,HTTP報文在網絡發送時傳輸層使用了TCP協議,TCP協議重傳和確認機制能夠確保HTTP報文到達接收端,因而HTTP協議是一種可靠的數據傳輸協議。
在這裏插入圖片描述

HTTP報文

爲了方便查看HTTP報文筆者搭建了簡單的JSP/Servlet網絡應用HttpServer並且部署在Tomcat服務器上,在應用的根路徑下有一個簡單的index.jsp文件。Charles網絡抓包工具能夠抓取本機發送和接收到的HTTP網絡數據,這裏就使用Charles抓包工具來查看瀏覽器請求index.jsp文件發送的HTTP報文。

GET /HttpServer/index.jsp HTTP/1.1 
Host: 192.168.137.240:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
 like Gecko) Chrome/69.0.3497.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,
image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=FBD78F118328969D843751AAD84162CB; 
_ga=GA1.1.2124913443.1537969664; jenkins-timestamper-offset=-28800000

在發送報文分成三個部分,第一行的請求行,中間的請求頭部分和最後的請求體部分,index.jsp的請求報文並不包含請求體部分,後面章節討論請求方式的時候會重點講解請求體。
在這裏插入圖片描述
請求行的GET代表請求獲取服務器上的資源,資源的路徑在服務器的/HttpServer/index.jsp路徑下,HTTP/1.1本次請求使用的HTTP版本是1.1版本。HTTP的資源請求方式除了GET獲取,用的最多的就是POST代表向服務器發送數據,其他還有五種請求方式相對來說使用的較少。

HTTP請求方式 備註
GET 請求服務器發送資源
HEAD 請求服務器資源,但不返回資源實體,只返回資源相關的頭部
POST 向服務器發送數據的,HTTP中最常見的數據交互方式
PUT 請求服務器用請求的主體部分來創建資源,相當於上傳主體數據
TRACE 用於獲取請求經過的網絡路徑信息
OPTIONS 獲取服務器支持的操作信息
DELETE 請求服務器刪除請求URL所指定的資源

在請求頭下面的多條鍵值對形式存在的頭部用來描述本次請求的屬性,Host代表服務器對象的IP地址和端口號,User-Agent代表使用的瀏覽器類型,Accept-*開頭的頭部表明本次請求支持返回的數據類型、編碼和語言。最後一個Cookie代表本次HTTP請求客戶端保存的Cookie信息,裏面有三個鍵值對,重點看一下JSESSIONID鍵值對。HTTP協議屬於無狀態協議,所謂的無狀態是指在HTTP協議本身並不會記錄之前處理的信息,如果後續的請求需要在之前的信息上做處理,那麼舊的數據必須重新傳遞給服務器端,重傳之前數據會導致資源浪費和效率低下,現代的HTTP客戶端會使用Cookie來保存之前的狀態信息,而服務器端會在Session對象中保存信息。Tomcat服務器內部就是用JSESSIONID值來代表同一個會話Session對象,只要客戶端請求裏帶了相同的JSESSIONID Cookie值這些請求就屬於同一個會話,它們就可以共用鍵值爲JSESSIONID的Session中的數據。

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Type: text/html;charset=UTF-8
Content-Length: 64
Date: Sat, 04 May 2019 10:17:30 GMT
Connection: Keep-alive

<html>
<head><title>Insert title here</title></head>
</html>

接着查看Tomcat服務器返回的index.jsp響應報文,響應報文也包含了三個部分,第一行的響應結果行,接着是多個鍵值對組成的響應頭部,響應頭部下方就是服務器返回的HTML內容。
在這裏插入圖片描述
響應結果行裏的狀態碼和狀態描述說明本次請求的響應結果,200代表請求響應成功,OK只是簡單的文本描述,HTTP定義多個響應狀態值,它們主要分成了五個區間。

HTTP響應碼 含義
100~199 消息型狀態碼
200~299 成功型狀態碼,通常表示本次請求成功或部分成功
300~399 重定向狀態碼,訪問的資源位置發生變化
400~499 客戶端錯誤狀態碼
500~599 服務器錯誤狀態碼

其中重定向狀態碼代表客戶端請求的資源位置發生變化,通常會返回Location消息頭中包含新的資源位置,比如訪問百度網站直接發送HTTP百度服務器就會要求用戶瀏覽器重定向到HTTPS連接地址。

HTTP/1.1 302 Moved Temporarily
Location: https://www.baidu.com/

在響應頭部分以Content-開頭的頭部是描述響應體類型的,index.jsp的類型就是text/html長度爲64個字節,Date代表本次響應發生的時間。Connection頭部代表要保持本次連接,不要立即將本次請求響應的網絡連接斷掉。在老版本的HTTP協議中客戶端和服務器完成一次請求響應就會斷掉之前的連接,下一次再做請求響應就重新創建連接,網絡連接的創建相對來說是非常耗時的,這種斷開重連的方式對服務器和網絡都會產生性能消耗,在新版本的HTTP中允許一次請求響應後保持連接方便後續的操作複用之前的連接。

請求和響應報文中的頭部主要用來增加一些附加信息,頭部主要分成四種:請求頭部,也就是只會在請求報文中出現的頭部;響應頭部,只會在響應報文中出現的頭部;通用頭部,既能在請求報文也可以在響應報文中出現的頭部;實體頭部,主要使用來描述請求體或者響應體裏面的實體對象信息的。

請求方式

HTTP協議中的請求方式多種多樣,不過在實際開發中最常用的請求方式主要有GET、POST和HEADER三種方式,現在就通過Android中的HttpUrlConnnection網絡接口和Tomcat服務器上部署的基於JSP/Servlet開發的HttpServer服務來學習上面三種請求方式的報文格式。

GET請求方式顧名思義就是從服務器獲取內容,在HTTP報文節貼出的是GET方式正常的index.jsp資源的請求和響應報文,有時服務器端的資源位置發生了變化,客戶端如果繼續使用資源以前的位置發送請求服務器可以通過返回302狀態碼並且附上Location頭告知客戶端向新的地址發送請求。

// HTTP GET網絡請求
HttpURLConnection httpURLConnection =
 (HttpURLConnection) new URL(getUrl).openConnection();
httpURLConnection.setRequestMethod("GET"); // 設置請求方式爲GET
httpURLConnection.setConnectTimeout(3000); // 設置連接超時時間爲3秒
httpURLConnection.setReadTimeout(3000); // 設置讀取數據超時時間爲3秒
httpURLConnection.setDoInput(true); // 本次請求可以做輸入操作
httpURLConnection.setDoOutput(false); // 本次請求不需要做輸入操作
httpURLConnection.setInstanceFollowRedirects(true); // 支持請求Http重定向
httpURLConnection.getResponseCode(); // 等待返回響應碼

printHeads(httpURLConnection.getHeaderFields()); // 打印響應報文頭部
InputStream inputStream = httpURLConnection.getInputStream(); // 讀取響應體數據
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int len = -1;
// 把響應體中的數據讀取到bos輸出流的內存中
while ((len = inputStream.read(buf)) != -1) { 
bos.write(buf, 0, len);
}
String text = new String(bos.toByteArray(), "UTF-8");
Log.e(TAG, text); // 輸出響應體內容
httpURLConnection.disconnect(); // 斷開網絡連接

需要注意高版本的Android中不允許在主線程執行網絡請求,需要開發者把網絡請求放到子線程中執行,在Android中調用上面的代碼並且用Charles抓包會看到兩個HTTP請求和兩個HTTP響應,報文中一些不重要的頭部沒有貼出來。

// 第一次請求hello.html請求報文
GET /HttpServer/hello.html HTTP/1.1
Host: 10.2.129.168:8080
     // 響應第一次請求302響應報文
HTTP/1.1 302 Found
Server: Apache-Coyote/1.1
Location: /HttpServer/newPath.jsp
     // 重定向後再發起第二次請求報文
GET /HttpServer/newPath.jsp HTTP/1.1
Host: 10.2.129.168:8080
     // 響應第二次請求的響應報文
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=B6B446A8A09978AE57B7FBBBEF3E96F0; 
Path=/HttpServer; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 276
Date: Fri, 17 May 2019 07:21:04 GMT
Connection: Keep-alive

<!DOCTYPE html >
<html>....</html>

可以看到在第一次響應返回的狀態碼是302,Location頭部資源新的路徑 /HttpServer/newPath.jsp,隨後HttpURLConnection內部解析出Location值並且發送新的請求,新的響應報文內部就包含了資源數據。在第二次返回的報文中包含了Set-Cookie響應頭,它的值包含了三個Cookie鍵值對數據,Cookie之間使用分號分割,鍵值對之間使用等於號分割,想要保存JSESSIONID需要對鍵值對做分割操作,保存下來的sessionId可以在下次請求時帶上,Tomcat服務器把具有相同sessionId的請求視作同一個會話,它們之間可以共享保存在服務器上的Session會話數據。

// 解析報文頭JSESSIONID信息
private void printHeads(Map<String, List<String>> headerFields) {
	for (Map.Entry<String, List<String>> entry : headerFields.entrySet()) {
		if ("set-cookie".equalsIgnoreCase(entry.getKey())) {
			List<String> cookies = entry.getValue();
			for (String cookie : cookies) {
				if (cookie.contains("JSESSIONID")) {
					sessionId = cookie.split("=")[1];
				}
			}
		}
	}
}

HttpURLConnection在初始化的時候設置了連接超時和讀取超時兩個屬性,它們有什麼區別呢?HTTP應用層協議實際上是運行在傳輸層的TCP協議之上的,TCP在建立連接的時候會有三次握手的過程,只有客戶端和服務端都進行了三次握手才真正建立起TCP連接,三次握手過程耗費的時間對應的就是連接超時設置時間,Wireshark截圖前三條TCP報文正是TCP建立連接發送的SYN和ACK報文。在連接建立之後兩端纔開始真正的發送數據,服務器的數據在網絡上傳輸由於網絡的質量的問題需要消耗一定的時間,讀取超時對應的就是數據傳輸時間。
在這裏插入圖片描述
HTTP網絡通信的底層是Socket對象,它能夠支持全雙工通信方式,也就是既能發送數據也可以接收數據,setDoInput()設置是否支持讀取網絡數,setDoOuput()則設置是否支持寫入網絡數據,只有設置支持輸入時HttpURLConnection.getInputStream()返回的輸入流纔是有效的,同理設置支持輸出時HttpURLConnection.getOutputStream()返回的輸出流纔是有效的。輸入和輸出流對象寫入的數據都是放在請求報文體裏面的,請求報文的頭部信息寫入需要使用HttpURLConnection.addRequestProperty(String header, String value)方法實現,而響應報文的頭部讀取需要用HttpURLConnection.getHeaderFields()方法。POST請求方式支持向服務器端發送數據,GET發送的數據一定要放在請求行的路徑裏,POST發送的數據既可以放在請求路徑裏也可以放到請求體中。

// 發起HTTP POST請求
httpURLConnection.setDoOutput(true); // 支持輸出數據
// 其他配置同HTTP GET請求
if (!TextUtils.isEmpty(sessionId)) { // 添加cookie請求頭部
	httpURLConnection.addRequestProperty("cookie", "JSESSIONID=" + sessionId);
}
OutputStream outputStream = httpURLConnection.getOutputStream();
outputStream.write("name=xxx&password=abcd1234".getBytes("UTF-8"));
outputStream.flush(); // 在請求體裏寫入請求參數

上面的代碼省略了HttpURLConnection初始化配置和最後的數據讀取操作,它們和前面的GET請求方式大體相似,不過再請求發送之前添加了Cookie請求頭並且在請求體裏添加請求參數,執行上面的POST請求方法代碼,查看Charles抓包的結果。

// POST請求報文
POST /HttpServer/unsafelogin HTTP/1.1
Content-Type: application/x-www-form-urlencoded
User-Agent: Dalvik/2.1.0 (Linux; U; Android 8.1.0; Nexus 6 Build/OPM7.181005.003)
Host: 10.2.129.168:8080
Connection: Keep-Alive
Accept-Encoding: gzip
Content-Length: 26

name=xxx&password=abcd1234

// POST響應報文
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=D235C4074E6035F04FEAF7C6489725FF; 
Path=/HttpServer; HttpOnly
Date: Fri, 17 May 2019 08:07:41 GMT
Transfer-Encoding: chunked
Proxy-Connection: Keep-alive

{"code":0,"data":{"age":20,"name":"xxx","sex":"Female"},"msg":"login success"}

請求報文包含了Content-Type: application/x-www-form-urlencoded請求頭,它代表請求體中的數據是普通的請求參數類型,在請求頭部下面會有一行空白行,空白行後面的就是請求體內容,正式前面代碼中寫入的請求參數。響應報文包含了Set-Cookie響應頭和響應體數據,通過數據可知本次登錄成功。不過這種使用HTTP協議做登錄其實是非常不安全的,後面會介紹使用HTTPS協議實現登錄。在代碼中會保存下此次登錄成功返回的JSESSIONID,接着再執行一次登錄請求。

POST /HttpServer/unsafelogin HTTP/1.1
cookie: JSESSIONID=54FC1377AA128CD8CB91D73AC5C866CA; Path
Content-Type: application/x-www-form-urlencoded
User-Agent: Dalvik/2.1.0 (Linux; U; Android 8.1.0; Nexus 6 Build/OPM7.181005.003)
Host: 10.2.129.168:8080
Connection: Keep-Alive
Accept-Encoding: gzip
Content-Length: 26

name=xxx&password=abcd1234

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Date: Fri, 17 May 2019 08:20:29 GMT
Transfer-Encoding: chunked
Proxy-Connection: Keep-alive

{"code":-10,"data":{"age":20,"name":"xxx","sex":"Female"},"msg":"alreay login!!!"}

可以看到由於保存過上一次的返回的JSESSIONID,本次請求頭中增加了cookie請求頭,服務器端會認爲兩次請求屬於同一個會話,根據會話中保存的登錄信息返回用戶已經登錄的響應數據。POST除了能夠傳遞簡單的鍵值對參數數據,還可以實現文件上傳功能,不過此時需要使用的請求頭Content-Type需要是multipart/form-data類型,爲了查看上傳文件的報文結構,可以使用Android第三方OkHttp框架來實現上傳操作,通過Charles抓包工具觀察請求響應報文。

// OkHttp實現文件上傳
OkHttpClient okHttpClient = new OkHttpClient();
RequestBody requestBody = RequestBody.create(MediaType.parse("image/png"),
	new File("ic_launcher.png"));
	MultipartBody body = new MultipartBody.Builder()
	.setType(MultipartBody.FORM)
	.addFormDataPart("filename", "ic_launcher.png", requestBody)
	.addFormDataPart("name", "xxx")
	.build();
Request request = new Request.Builder()
	.url("http://10.2.129.168:8080/HttpServer/upload ")
	.post(body)
	.build();
try {
	Response response = okHttpClient.newCall(request).execute();
	if (response.isSuccessful()) {
		System.out.println(response.body().string());
	}
} catch (IOException e) {
		e.printStackTrace();
}

執行上面的上傳文件代碼後觀察上傳請求和響應報文,請求報文的Content-Type頭部還定義了一個boundary請求體數據分隔符,它的值是隨機的,不是固定的值。請求體會以兩個橫槓加boundary開頭,接着是Content-Disposition描述參數的名稱和文件名,如果是文件類型還有Content-Type描述文件類型,Content-Length指明參數值的長度。每個參數之間都是用雙橫槓加boundary分割,在最後一個參數的結尾處還需要添加左右都有兩個橫槓的boundary代表數據結束。

// 上傳請求報文
POST /HttpServer/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=71f62f86-7932-4ba5-bdb9-0deda48ef376
Transfer-Encoding: chunked
Host: 10.2.129.168:8080
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.10.0
    
--71f62f86-7932-4ba5-bdb9-0deda48ef376  // 參數之間的邊界只有開頭有兩條橫線
Content-Disposition: form-data; name="filename"; filename="ic_launcher.png"
Content-Type: image/png
Content-Length: 52586
    
.....
    
--71f62f86-7932-4ba5-bdb9-0deda48ef376  // 參數之間的邊界只有開頭有兩條橫線
Content-Disposition: form-data; name="name"
Content-Length: 3
    
xxx
--71f62f86-7932-4ba5-bdb9-0deda48ef376--  // 結束的邊界前後都有兩個橫線

瞭解了上傳文件請求報文的格式就可以使用HttpURLConnection來實現上傳文件功能,只要正確的設置了Content-Type請求頭,請求體的內容正確拼接在一起就能夠將文件上傳到服務器。下面的代碼通過生成和代OkHttp下載請求相同的報文向服務器端發送上傳文件HTTP請求,測試能夠正常執行。

// 文件上傳實現
// 設置Content-Type請求頭
String boundary = "71f62f86-7932-4ba5-bdb9-0deda48ef376";
httpURLConnection.addRequestProperty("Content-Type", 
				"multipart/form-data; boundary=" + boundary);

// 拼接上傳文件請求體
OutputStream outputStream = httpURLConnection.getOutputStream();
// 添加文件參數
StringBuilder builder = new StringBuilder("--" + boundary);
builder.append("\r\n");
builder
	.append("Content-Disposition: form-data; name=\"filename\";filename=\"hello.mp4\"")
	.append("\r\n");
	.append("Content-Type: video/mp4").append("\r\n");
byte[] buf = new byte[1024];
InputStream data = getAssets().open("swim.mp4");
builder.append("Content-Length: ").append(data.available()).append("\r\n");
builder.append("\r\n");
outputStream.write(builder.toString().getBytes("UTF-8"));
// 向方法體寫入文件內容
int dataLen = -1;
while ((dataLen = data.read(buf)) != -1) {
	outputStream.write(buf, 0, dataLen);
}
outputStream.write("\r\n".getBytes("UTF-8"));
// 寫入結尾邊界符
outputStream.write(("--" + boundary + "--").getBytes("UTF-8"));
outputStream.flush();

既然有了上傳文件功能,自然也需要有下載文件的功能,下載文件完全可以使用GET請求方式,下載文件和普通的請求資源實現完全一致,不過鑑於文件內容可能比較大HTTP協議提供了Range請求頭,也就是說只請求文件的一部分數據,在斷點續傳和多線程下載中通常都會使用到。
HEAD請求只會返回請求結果的響應頭,它在多線程下載的時候通常非常有用,需要注意的是HEAD請求一定不要啓用輸出數據的選項,HTTP協議開發者認爲如果需要輸出數據建議使用POST請求類型,因此如果在HttpURLConnection做HEAD請求啓用setDoOutput(true)就會拋出異常。

//  HEAD請求實現
// 設置setDoOutput(true)拋出java.net.ProtocolException: 
// HEAD does not support writing
httpURLConnection.setRequestMethod("HEAD"); // 設置HEAD請求方式
httpURLConnection.setDoInput(true);
httpURLConnection.setDoOutput(false);
httpURLConnection.getResponseCode();
// 其他與GET配置相同

// 查看Charles抓包結果看到下載文件時只返回了響應頭部分,文件內容並沒有隨之返回,
// 在響應頭裏包含了要下載文件的描述信息和文件長度Content-Length。
// HEAD請求報文
HEAD /HttpServer/download?filename=sport.mp4 HTTP/1.1
User-Agent: Dalvik/2.1.0 (Linux; U; Android 8.1.0; Nexus 6 Build/OPM7.181005.003)
Host: 192.168.137.240:8080
Connection: Keep-Alive
Accept-Encoding: gzip

 // HEAD響應報文,只有頭部信息,沒有響應體
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Disposition: attachment; filename=sport.mp4
Content-Type: video/mp4
Content-Length: 1898889
Date: Fri, 17 May 2019 13:55:09 GMT
Connection: Keep-alive

在瞭解Content-Length之前需要了解HTTP的連接持久化概念,HTTP連接工作在TCP協議之上,TCP在建立連接過程中需要三次握手, 如果連接不成功還需要執行慢啓動,因而建立TCP連接是比較耗時的操作。在HTTP舊版本上一次請求響應操作就執行一次TCP連接建立和斷開,這種連接方式被稱作短連接,所謂的持久化連接其實就是長連接,HTTP一次請求響應後不會直接斷開連接,下一次請求數據時直接複用已有的長連接,長連接的數據發送效率更高。
多個響應數據在長連接上發送會出現響應數據邊界無法確定的問題,爲了解決響應邊界問題HTTP在響應頭中增加了Content-Length頭部,客戶端在讀取數據時只要讀夠了Content-Length長度的數據就代表本次響應數據讀取完成。Content-Length的值必須是精確的,如果返回的數據不是服務器本地數據,還需要到其他的網絡上獲取數據,服務器端就必須把所有的數據都讀取回來,最後設置正確的Content-Length值返回給客戶端。很顯然如果請求的數據比較大,服務器端等到數據請求完成才向客戶端發送響應,客戶端請求和響應的時間過長,用戶體驗非常的差。
爲了解決長連接大數據傳輸響應慢的問題,HTTP提供了分塊傳輸機制,響應頭Transfer-Encoding: chunked就代表服務器端開啓的分塊傳輸機制,分塊傳輸會將數據分成多個塊,每個塊包含一個塊數據長度和塊數據,最後一個塊長度必須爲0代表整個數據傳輸完成。分塊傳輸內部會增加長度字段,實際上傳遞的數據長度不能確定,因此Content-Length和Transfer-Encoding: chunked是互斥出現的。

多線程下載

服務器端本地的文件通常都可以獲取它們的長度值,不需要使用分塊傳輸技術,只使用一個線程下載大文件會比較慢,客戶端此時可以開啓多個線程同時下載文件。多線程下載首先需要通過HEAD請求得到要下載文件的長度,之後爲每個線程分配下載的數據區間,每個線程內部採用POST請求方式下載文件數據。
在這裏插入圖片描述
爲每個線程分配好下載區間值後用POST請求方式下載數據,同時增加Range請求頭指定要下載數據的區間,服務器端會讀取區間數據並返回區間數據,客戶端使用RandomAccessFile接收區間數據並且寫入文件中,由於多個線程寫入的文件區間不同實際上並不存在共享數據問題,也就不需要考慮線程安全問題。

// HTTP多線程下載
// 開啓資源HEAD請求
httpURLConnection.setRequestMethod("HEAD");
httpURLConnection.setDoInput(true);
httpURLConnection.setDoOutput(false);
httpURLConnection.getResponseCode();
int contentLength = httpURLConnection.getContentLength();
httpURLConnection.disconnect();

int size = contentLength / 4; // 開啓四個線程,計算平均每個線程下載的長度值,可能有餘數
for (int i = 0; i < 4; i++) {
	final int start = size * i;
	int end = start + size - 1;
	if (i == 3) { // 最後一個線程需要下載平均長度+餘數長度
		end = contentLength;
	}

	final int startPos = start;
	final int endPos = end;
	// 開啓新線程下載startPos-endPos的數據
}

// 開啓部分下載
httpURLConnection.setRequestMethod("GET");
httpURLConnection.setDoInput(true);
httpURLConnection.setDoOutput(false);
// 添加請求數據區間Range:bytes:startPos-endPos
httpURLConnection.addRequestProperty("Range", "bytes:" + startPos + "-" + endPos);
httpURLConnection.getResponseCode();
httpURLConnection.getResponseCode();

fileSize = httpURLConnection.getContentLength();
InputStream inputStream = httpURLConnection.getInputStream();
int len = -1;
byte[] buf = new byte[1024];
final RandomAccessFile file = new RandomAccessFile(
getCacheDir().getAbsolutePath() + File.separator + "panda.jpg", "rw");
file.seek(startPos);  // 寫入位置定位到startPos
while ((len = inputStream.read(buf)) != -1) {
	file.write(buf, 0, len);
}
file.close();

上面的代碼實現了簡單的多線程下載,在開始下載前先通過HEAD方法請求要下載的文件,由於HEAD請求的響應體只會返回對應的頭部信息,下載文件信息根本不會被返回,客戶端就能夠輕鬆獲取要下載的文件大小。得到文件大小後需要爲每個線程分配需要下載的文件數據區間,通常按照線程個數平均分配,不過文件大小很可能出現剩餘一小段的問題,爲此下載最後一段的線程通常就要負責將平均分配後剩餘的那小段文件數據一同下載。客戶端每個線程在請求服務器數據時需要帶上Range: startPos-endPos請求頭,服務器端讀取到該請求頭後只會返回該區間段內的文件數據回來。客戶端在接收到部分數據後需要寫入到本地文件中,RandomAccessFile支持在寫入讀取時先定位到某個位置,調用seek(startPos)就能將寫入文件指針位置調整到下載區間開始位置,四個線程都是用同樣的方式寫入下載的個部分數據,當所有線程全部執行完畢後,本地文件中就包含了所有數據。

HTTP緩存機制

服務器端的有些數據不是經常變化的,客戶端訪問過一次後可以把服務端的數據緩存下來,之後再需要請求對應的數據只需要判斷緩存是否過期或者向服務器求證緩存內容是否發生變化,未過期或未變化的數據都可以繼續使用,緩存處理可以減少用戶流量,提高數據加載速度。
在HTTP早期版本中有一個Expires響應頭部,Expires的內容就是數據過期的時間,HTTP在請求數據時只要把過期時間和系統時間作對比,小於過期時間直接使用緩存數據即可,否則要重新向服務器端請求新數據。Expires的缺點很明顯如果客戶端系統時間不精確就會導致數據過期判斷失誤,因此在HTTP新版本中Expires響應頭已經被廢棄。
HTTP新版本中使用了Cache-Control響應頭部來指定緩存策略,它包含常見的取值有private、public、no-cache、max-age,no-store,默認爲private。

Cache-Control頭部字段 備註
private 只有客戶端內部可以緩存
public 客戶端和代理服務器都可緩存
max-age=xxx 緩存的內容將在 xxx 秒後失效
no-cache 不使用max-age來判定緩存有效,使用請求頭If-Modified-Since或If-None-Match判定
no-store 禁止任何形式的緩存,也即不緩存網絡數據

在這裏插入圖片描述
除了Cache-Control的max-age判定緩存是否有效,HTTP還提供了Etag/If-None-Match響應請求頭判定文件內容是否改變,Etag的內容可以認爲是資源文件的MD5運算結果,如果發生變化Etag值就發生變化。在客戶端第一次請求資源時服務器端會將資源數據連同Etag一起返回給客戶端,客戶端後續再請求該資源會帶上If-None-Match:etag請求頭,服務器根據請求頭判定緩存在客戶端的數據是否過期,未過期就返回304狀態碼,否則返回新的資源數據。
在這裏插入圖片描述
Last-Modified/If-Modified-Since的緩存判定策略與Etag基本類似,不過If-Modified-Since請求頭髮送給服務器的是資源的上次修改時間。服務器的資源通常都是開發者提供的,如果開發者不小心在資源文件內部多加了空行就會導致修改時間發生變化,實際上資源的內容並沒有發生改變,因此Etag和Last-Modified同時存在的時候Etag的優先級更高。
Cache-Control、Etag、Last-Modified三種緩存策略的優先級從高到低排序,如果它們同時存在高優先級的緩存策略生效,低優先級的自動被忽略。在Android網絡開發中HTTP緩存其實使用的比較少,這裏就只做原理性介紹。
HTTP網絡編程測試Demo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章