1. 程式人生 > >Servlet實現檔案上傳的原理

Servlet實現檔案上傳的原理

Servlet 是用 Java 編寫的、協議和平臺都獨立的伺服器端元件,使用請求/響應的模式,提供了一個基於 Java 的伺服器解決方案。使用 Servlet 可以方便地處理在 HTML 頁面表單中提交的資料,但 Servlet 的 API 沒有提供對以 mutilpart/form-data 形式編碼的表單進行解碼的支援,因而對日常應用中經常涉及到到檔案上傳等事務無能為力。本文將從檔案傳輸的基本原理入手,分析如何用 Servlet 進行檔案的上傳,並提出解決方案。

一、基本原理

通過 HTML 上載檔案的基本流程如下圖所示。瀏覽器端提供了供使用者選擇提交內 容的介面(通常是一個表單),在使用者提交請求後,將檔案資料和其他表單資訊 編碼並上傳至伺服器端,伺服器端(通常是一個 cgi 程式)將上傳的內容進行解 碼了,提取出 HTML 表單中的資訊,將檔案資料存入磁碟或資料庫。

二、各過程詳解

A)填寫表單並提交

通過表單提交資料的方法有兩種,一種是 GET 方法,另一種是 POST 方法, 前者通常用於提交少量的資料,而在上傳檔案或大量資料時,應該選用 POST 方法。在 HTML 程式碼中,在 <form> 標籤中新增以下程式碼可以 頁面上顯示一個選擇檔案的控制元件。

<input type="file" name="file01">

在頁面中顯示如下(可能隨瀏覽器不同而不同)

可以直接在文字框中輸入檔名,也可以點選按鈕後彈出供使用者選擇檔案的對話方塊。

B)瀏覽器編碼

在向伺服器端提交請求時,瀏覽器需要將大量的資料一同提交給 Server 端, 而提交前,瀏覽器需要按照 Server 端可以識別的方式進行編碼,對於普通 的表單資料,這種編碼方式很簡單,編碼後的結果通常是 field1=value2&field2=value2&… 的形式,如 name=aaaa&Submit=Submit。這種編碼的具體規則可以在 rfc2231 裡查到, 通常使用的表單也是採用這種方式編碼的,Servlet 的 API 提供了對這種 編碼方式解碼的支援,只需要呼叫 ServletRequest 類中的方法就可以得到 使用者表單中的欄位和資料。

這種編碼方式( application/x-www-form-urlencoded )雖然簡單,但對於 傳輸大塊的二進位制資料顯得力不從心,對於傳輸這類資料,瀏覽器採用 了另一種編碼方式,即 "multipart/form-data" 的編碼方式,採用這種方式, 瀏覽器可以很容易的表單內的資料和檔案一起。這種編碼方式先定義好 一個不可能在資料中出現的字串作為分界符,然後用它將各個資料段 分開,而對於每個資料段都對應著 HTML 頁面表單中的一個 Input 區,包 括一個 content-disposition 屬性,說明了這個資料段的一些資訊,如果這個 資料段的內容是一個檔案,還會有 Content-Type 屬性,然後就是資料本身。 這裡,我們可以編寫一個簡單的 Servlet 來看到瀏覽器到底是怎樣編碼的。

實現流程:

1、重載 HttpServlet 中的 doPost 方法。

2、調用 request.getContentLength() 得到 Content-Length ,並定義一個與 Content-Length 大小相等的位元組陣列buffer。

3、從HttpServletRequest 的例項 request 中得到一個 InputStream, 並把它讀入 buffer 中。

4、使用用FileOutputStream 將 buffer 寫入指定檔案。

程式碼清單:

// ReceiveServlet.java
import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
//示例程式:記錄下Form提交上來的資料,並存儲到Log檔案中
public class  ReceiveServlet extends HttpServlet
{
    public void doPost(HttpServletRequest request,HttpServletResponse response)
    throws IOException, ServletException
    {
//1
        int len = request.getContentLength();
        byte buffer[] = new byte[len];
//2
        InputStream in = request.getInputStream();
        int total = 0;
        int once = 0;
        while ((total < len) && (once >=0)) {
            once = in.read(buffer,total,len);
            total += once;
        }
//3
        OutputStream out=new BufferedOutputStream(
            new FileOutputStream("Receive.log",true));
        byte[] breaker="\r\nNewLog: -------------------->\r\n".getBytes();
        System.out.println(request.getContentType());
        out.write(breaker,0,breaker.length);
        out.write(buffer);
        out.close();
    }
}

在使用Opera作為瀏覽器測試時,從指定的檔案(Receive.log)中可以看到如下的內容

--_OPERAB__-T/DQLi2fn47+D52OOrpdrz
Content-Disposition: form-data; name="id"
id00
--_OPERAB__-T/DQLi2fn47+D52OOrpdrz
Content-Disposition: form-data; name="file3"; filename="Autoexec.bat"
Content-Type: application/octet-stream
@echo off
prompt $d $t [ $p ]$_$$
--_OPERAB__-T/DQLi2fn47+D52OOrpdrz--

這裡 _OPERAB__-T/DQLi2fn47+D52OOrpdrz就是瀏覽器指定的分界符,不同的瀏覽器有不同的確定分界符的方法,但都需要保證分界符不會在檔案內容中出現。下面是用 IE 進行測試的結果

-----------------------------7d137a26e18
Content-Disposition: form-data; name="name"
123
-----------------------------7d137a26e18
Content-Disposition: form-data; name="introduce"
I am...
  I am..
-----------------------------7d137a26e18
Content-Disposition: form-data; name="file3"; filename="C:\Autoexec.bat"
Content-Type: application/octet-stream
@echo off
prompt $d $t [ $p ]$_$$
SET PATH=d:\pf\IBMVJava2\eab\bin;%PATH%;D:\PF\ROSE98I\COMMON
-----------------------------7d137a26e18--

這裡 ---------------------------7d137a26e18 作為分界符。 關於分界符的規則可以概況為兩條:

1、除了最後一個分界符,每個分界符後面都加一個 CRLF 即 '\u000D' 和 '\u000A', 最後一個分界符後面是兩個分隔符"--"。

2、每個分界符的開頭也要加一個 CRLF 和兩個分隔符("-")。

瀏覽器採用預設的編碼方式是 application/x-www-form-urlencoded , 可以通過指定 form 標籤中的 enctype 屬性使瀏覽器知道此表單是用 multipart/form-data 方式編碼如:

< form action="/servlet/ReceiveServlet" ENCTYPE="multipart/form-data" method=post >

C)提交請求

提交請求的過程由瀏覽器完成的,並且遵循 HTTP 協議,每一個從瀏覽 器端到伺服器端的一個請求,都包含了大量與該請求有關的資訊, 在 Servlet 中,HttpServletRequest 類將這些資訊封裝起來,便於我們提取 使用。在檔案上載和表單提交的過程中,有兩個指的關心的問題,一是 上載的資料是是採用的那種方式的編碼,這個問題的可以從 Content-Type 中得到答案,另一個是問題是上載的資料量有多少即 Content-Length , 知道了它,就知道了 HttpServletRequest 的例項中有多少資料可以讀取 出來。這兩個屬性,我們都可以直接從 HttpServletRequest 的一個例項 中獲得,具體呼叫的方法是 getContentType() 和 getContentLength() 。

Content-Type 是一個字串,在上面的例子中,增加

System.out.println(request.getContentType());

可以得到這樣的一個輸出字串:

multipart/form-data; boundary=---------------------------7d137a26e18

前半段正是編碼方式,而後半段正是分界符,通過 String 類中的方法, 我們可以把這個字串分解,提取出分界符。

String contentType=request.getContentType();
int start=contentType.indexOf("boundary=");
int boundaryLen=new String("boundary=").length();
String boundary=contentType.substring(start+boundaryLen);
boundary="--"+boundary;

判斷編碼方式可以直接用 String 類中的 startsWith 方法判斷。

if(contentType==null || !contentType.startsWith("multipart/form-data"))

這樣,我們在解碼前可以知道:
編碼的方式是否是multipart/form-data
資料內容的分界符
資料的長度

我們可以用類似於 ReceiveServlet 中的方式將這個請求的輸入流讀 入一個長度為 Content-Length 的位元組陣列,接下來就是將這個位元組數組裡 的內容全部提取出來了。

D)解碼

解碼對我們來說是整個上載過程最繁瑣的一個步驟,經過以上的流程, 我們可以得到一個包含有所有上載資料的一個位元組陣列和一個分界符, 通過對 Receive.log 分析,還可以得到每個資料段中的分界符。 而我們要得到以下內容:

1、提交的表單中的各個欄位以及對應的值。

2、如果表單中有 file 控制元件,並且使用者選擇了上載檔案, 則需要分析出欄位的名稱、檔案在瀏覽器端的名字、檔案的 Content-Type 和檔案的內容。

位元組陣列的內容可以分解如下:

具體解碼過程也可以分為兩個步驟:

1、將上載的資料分解成資料段,每個資料段對應著表單中的一個 Input 區。

2、對每個資料段,再進行分解,提出上述要求得到的內容。

這兩個步驟主要的操作有兩個,一個是從一個數組中找出另一個數組的位置,類似於 String 類中的 indexOf 的功能,另一個是從一個數組中提取出另一個數組, 類似於 String 類中的 substring 的功能,為此我們可以專門寫兩個方法,實現這種功能。

int byteIndexOf (byte[] source,byte[] search,int start)
byte[] subBytes(byte[] source,int from,int end)

為了便於使用,可以從這兩個方法中衍生出下列方法

int byteIndexOf (byte[] source,String search,int start)   以一個 String 作為搜尋物件引數
String subBytesString(byte[] source,int from,int end)     直接返回一個 String
int bytesLen(String s)                  返回字串轉化為位元組陣列後,位元組陣列的長度

這樣,從一個位元組陣列中,根據標記提取出另一個位元組陣列可以表示如下:

假設我們已經將資料存入位元組陣列 buffer 中,分界符存入 String boundary 中

int pos1=0;            //pos1 記錄 在buffer 中下一個 boundary 的位置
                                     //pos0,pos1 用於 subBytes 的兩個引數
        int   pos0=byteIndexOf(buffer,boundary,0);
                                     //pos0 記錄 boundary 的第一個位元組在buffer 中的位置
        do
        {
            pos0+=boundaryLen;
//記錄boundary後面第一個位元組的下標
            pos1=byteIndexOf(buffer,boundary,pos0);
            if (pos1==-1)
                break;
            pos0+=2;          //考慮到boundary後面的 \r\n
            PARSE[(subBytes(buffer,pos0,pos1-2));]
                                   //考慮到boundary後面的 \r\n
            pos0=pos1;
        }while(true);

其中 PARSE 部分是對每一個數據段進行解碼的方法,考慮到 Content-Disposition 等屬性,首先定義一個 String 陣列

String[] tokens={"name=\"",
    "\"; filename=\"",
    "\"\r\n",
    "Content-Type: ",
    "\r\n\r\n"
    };

對於一個不是檔案的資料段,只可能有 tokens 中的第一個元素和最後一個元素,如果是一個檔案資料段,則包含所有的元素。第一步先得到 tokens 中每個元素在這個資料段中的位置

int[] position=new int[tokens.length];
        for (int i=0;i < tokens.length ;i++ )
        {
            position[i]=byteIndexOf(buffer,tokens[i],0);
        }

第二步判斷是否是一個檔案資料段,如果是一個檔案 資料段則 position[1] 應該大於0,並且 postion[1] 應該小於 postion[2] 即 position[1] > 0 && position[1] < position[2] 如果為真,則為一個檔案資料段,

1.得到欄位名
String name =subBytesString(buffer,position[0]+bytesLen(tokens[0]),position[1]);
2.得到檔名
String file= subBytesString(buffer,position[1]+bytesLen(tokens[1]),position[2]);
3.得到 Content-Type
String contentType=subBytesString(buffer,position[3]+bytesLen(tokens[3]),position[4]);
4.得到檔案內容
byte[] b=subBytes(buffer,position[4]+bytesLen(tokens[4]),buffer.length);
否則,說明資料段是一個 name/value 型的資料段,
且name 在 tokens[0] 和 tokens[2] 之間,value 在 tokens[4]之後
//1.得到 name
String name =subBytesString(buffer,position[0]+bytesLen(tokens[0]),position[2]);
//2.得到 value
String value= subBytesString(buffer,position[4]+bytesLen(tokens[4]),buffer.length);

三、具體實現

為便於使用,定義 upload 包,包括以下類:
ContentFactory
對從 client 中傳來的資料進行解碼,並提供一系列 get 方法,從中得到上傳的各種資訊。
具體介面如下

staticContentFactory getContentFactory(javax.servlet.http.HttpServletRequestrequest)
返回根據當前請求生成的一個 ContentFactory 例項
staticContentFactory getContentFactory(javax.servlet.http.HttpServletRequestrequest, intmaxLength)
返回根據當前請求生成的一個 ContentFactory 例項
FileHolder getFileParameter(java.lang.Stringname)
返回一個 FileHolder 例項,該例項包含了通過欄位名為 name 的 file 控制元件上載的檔案資訊, 如果不存在這個欄位或者提交頁面時,沒有選擇上載的檔案,則返回 null。
java.util.Enumeration getFileParameterNames()
返回一個 由 String 物件構成的 Enumeration ,包含了 Html 頁面 窗體中所有 file 控制元件的 name 屬性。
FileHolder[] getFileParameterValues(java.lang.Stringname)
返回一個 FileHolder 陣列,該陣列包含了所有通過欄位名為 name 的 file 控制元件上載的檔案資訊, 如果不存在這個欄位或者提交頁面時,沒有選擇任何上載的檔案,則返回一個 零元素的陣列(不是 null )。
java.lang.String getParameter(java.lang.Stringname)
String型別返回請求的引數的值,如果該引數不存在,則返回為null。引數存於提交的表單資料中。
java.util.Enumeration getParameterNames()
返回一個String型別的Enumeration物件,該物件包含了所有提交請求的引數名稱。
java.lang.String[] getParameterValues(java.lang.Stringname)
返回String型別的陣列,該陣列包含了指定名稱的引數對應的所有的值,如果引數不存在,則返回為null

FileHolder
封裝一個檔案資料段,可以從中提取檔名, Content-Type 和檔案內容等屬性。 介面如下:

byte[] getBytes()
返回一個檔案內容的位元組陣列
java.lang.String getContentType()返回該檔案的 Content-Type
java.lang.String getFileName()
返回該檔案在檔案上載前在客戶端的名稱
java.lang.String getParameterName()
返回上載該檔案時,Html 頁面窗體中 file 控制元件的 name 屬性
void saveTo(java.io.Filefile)
把檔案的內容存到指定的檔案中
void saveTo(java.lang.Stringname)
把檔案的內容存到指定的檔案中

ContentFactoryException
在 ContentFactory.getContentFactory 方法中可能丟擲。
各類的原始檔詳解程式碼清單。

四、使用示例

附錄中包含了一個 Servlet 示例,該示例過載了 HttpServlet 的兩個方法 ( doGet, doPost ),在瀏覽器傳送 GET 請求時,產生一個表單,在使用者提交表單時, 將檔案和資料上載,並在瀏覽器端顯示出上載檔案存檔後的 URL , 以及頁面中的各欄位的 name 和 value 。該示例及各類在Windows98、jdk1.3和tomcat3.1, 瀏覽器為IE5和Opera3.6的環境下除錯通過。

五、附錄

程式碼清單

示例及整個 upload 包,以及 javadoc 生成的 API 文件source.zip)。