Servlet 是用 Java 编写的、协议和平台都独立的服务器端组件,使用请求/响应的模式,提供了一个基于 Java 的服务器解决方案。使用 Servlet 可以方便地处理在 HTML 页面表单中提交的数据,但 Servlet 的 API 没有提供对以 mutilpart/form-data 形式编码的表单进行解码的支持,因而对日常应用中经常涉及到到文件上传等事务无能为力。本文将从文件传输的基本原理入手,分析如何用 Servlet 进行文件的上传,并提出解决方案。
一、基本原理
通过 HTML 上载文件的基本流程如下图所示。浏览器端提供了供用户选择提交内容的界面(通常是一个表单),在用户提交请求后,将文件数据和其他表单信息编码并上传至服务器端,服务器端(通常是一个 cgi 程序)将上传的内容进行解码,提取出 HTML 表单中的信息,将文件数据存入磁盘或数据库。
</td> </tr> <tr> <td height="20" colspan="2">二、各过程详解
A)填写表单并提交
通过表单提交数据的方法有两种,一种是 GET 方法,另一种是 POST 方法,前者通常用于提交少量的数据,而在上传文件或大量数据时,应该选用 POST 方法。在 HTML 代码中,在 <form> 标签中添加以下代码可以页面上显示一个选择文件的控件。
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td><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 来看到浏览器到底是怎样编码的。
实现流程:
// 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 作为分界符。关于分界符的规则可以概况为两条:
浏览器采用默认的编码方式是 application/x-www-form-urlencoded ,可以通过指定 form 标签中的 enctype 属性使浏览器知道此表单是用 multipart/form-data 方式编码如:
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>< form action="/servlet/ReceiveServlet" ENCTYPE="multipart/form-data" method=post >
      C)提交请求
提交请求的过程由浏览器完成的,并且遵循 HTTP 协议,每一个从浏览器端到服务器端的一个请求,都包含了大量与该请求有关的信息, 在 Servlet 中,HttpServletRequest 类将这些信息封装起来,便于我们提取使用。在文件上载和表单提交的过程中,有两个指的关心的问题,一是上载的数据是是采用的那种方式的编码,这个问题的可以从 Content-Type 中得到答案,另一个是问题是上载的数据量有多少即 Content-Length ,知道了它,就知道了 HttpServletRequest 的实例中有多少数据可以读取出来。这两个属性,我们都可以直接从 HttpServletRequest 的一个实例中获得,具体调用的方法是 getContentType() 和 getContentLength() 。
Content-Type 是一个字符串,在上面的例子中,增加
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>System.out.println(request.getContentType());可以得到这样的一个输出字符串:
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>multipart/form-data; boundary=---------------------------7d137a26e18
      前半段正是编码方式,而后半段正是分界符,通过 String 类中的方法,我们可以把这个字符串分解,提取出分界符。
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>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 方法判断。
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>if(contentType==null || !contentType.startsWith("multipart/form-data"))
      这样,我们在解码前可以知道: 
                          编码的方式是否是multipart/form-data 
                          数据内容的分界符 
                          数据的长度 
我们可以用类似于 ReceiveServlet 中的方式将这个请求的输入流读入一个长度为 Content-Length 的字节数组,接下来就是将这个字节数组里的内容全部提取出来了。
D)解码
解码对我们来说是整个上载过程最繁琐的一个步骤,经过以上的流程,我们可以得到一个包含有所有上载数据的一个字节数组和一个分界符,通过对 Receive.log 分析,还可以得到每个数据段中的分界符。而我们要得到以下内容:
字节数组的内容可以分解如下:
具体解码过程也可以分为两个步骤:
这两个步骤主要的操作有两个,一个是从一个数组中找出另一个数组的位置,类似于 String 类中的 indexOf 的功能,另一个是从一个数组中提取出另一个数组, 类似于 String 类中的 substring 的功能,为此我们可以专门写两个方法,实现这种功能。
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>int byteIndexOf (byte[] source,byte[] search,int start)
      byte[] subBytes(byte[] source,int from,int end)
      为了便于使用,可以从这两个方法中衍生出下列方法
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>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 中
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>       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 数组
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>   String[] tokens={"name=\"",
      "\"; filename=\"",
      "\"\r\n",
      "Content-Type: ",
      "\r\n\r\n"
      };
      对于一个不是文件的数据段,只可能有 tokens 中的第一个元素和最后一个元素,如果是一个文件数据段,则包含所有的元素。第一步先得到 tokens 中每个元素在这个数据段中的位置
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>       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] 如果为真,则为一个文件数据段,
<table cellSpacing=0 cellPadding=5 width="100%" bgColor=#eeeeee border=1> <tr> <td>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 </td>
                              <td>getContentFactory (javax.servlet.http.HttpServletRequestrequest) staticContentFactory </td>
                              <td>getContentFactory (javax.servlet.http.HttpServletRequestrequest, intmaxLength) FileHolder </td>
                              <td>getFileParameter (java.lang.Stringname) java.util.Enumeration </td>
                              <td>getFileParameterNames () FileHolder[] </td>
                              <td>getFileParameterValues (java.lang.Stringname) java.lang.String </td>
                              <td>getParameter (java.lang.Stringname) String 类型返回请求的参数的值,如果该参数不存在,则返回为 null 。参数存于提交的表单数据中。 </td>
                            </tr>
                            <tr vAlign=top>
                              <td vAlign=top align=right width="1%">java.util.Enumeration </td>
                              <td>getParameterNames () String 类型的 Enumeration 对象,该对象包含了所有提交请求的参数名称。 </td>
                            </tr>
                            <tr vAlign=top>
                              <td vAlign=top align=right width="1%">java.lang.String[] </td>
                              <td>getParameterValues (java.lang.Stringname) String 类型的数组,该数组包含了指定名称的参数对应的所有的值,如果参数不存在,则返回为 null。 </td>
                            </tr>
                          
                        </table>
                        FileHolder 
                          封装一个文件数据段,可以从中提取文件名, Content-Type 和文件内容等属性。 接口如下: 
byte[] </td>
                              <td>getBytes () java.lang.String </td>
                              <td>getContentType () 返回该文件的 Content-Type </td>
                            </tr>
                            <tr vAlign=top>
                              <td vAlign=top align=right width="1%">java.lang.String </td>
                              <td>getFileName () java.lang.String </td>
                              <td>getParameterName () void </td>
                              <td>saveTo (java.io.Filefile) void </td>
                              <td>saveTo (java.lang.Stringname) ContentFactoryException 
                          在 ContentFactory.getContentFactory 方法中可能抛出。 
                    各类的源文件详解代码清单。 
四、使用示例
附录中包含了一个 Servlet 示例,该示例重载了 HttpServlet 的两个方法 ( doGet, doPost ),在浏览器发送 GET 请求时,产生一个表单,在用户提交表单时,将文件和数据上载,并在浏览器端显示出上载文件存盘后的 URL ,以及页面中的各字段的 name 和 value 。该示例及各类在Windows98、jdk1.3和tomcat3.1,浏览器为IE5和Opera3.6的环境下调试通过。
五、附录
参考资料
李常庚 , Java 编程爱好者
                    
 
↑返回目录 
前一篇: UTF-8 字符处理在 Web 开发中的应用  
 
后一篇: JSP海量统计系统(1.0版)