概 述
文件上傳和下載是 Web 應用中的一個常見功能,相信各位或多或少都曾寫過這方面相關的代碼。但本座看過不少人在實現上傳或下載功能時總是不知不覺間與程序的業務邏輯糾纏在一起,因此,當其他地方要用到這些功能時則無可避免地 Copy / Pase,然后再進行修改。這樣丑陋不堪的做法導致非常容易出錯不說,更大的問題是嚴重浪費時間不斷做重復類似的工作,這是本座絕不能容忍的。哎,人生苦短啊,浪費時間在這些重復工作身上實在是不值得,何不把這些時間省出來打幾盤羅馬或者踢一場球?為此,本座利用一些閑暇之時光編寫了一個通用的文件上傳和文件下載組件,實現方法純粹是基于 JSP,沒有太高的技術難度,總之老少咸宜 ^_^?,F把設計的思路和實現的方法向各位娓娓道來,希望能起到拋磚引玉的效果,激發大家的創造性思維。
任何公共組件的設計都必須考慮下面兩個問題:
一、如何才能重用?
答:首先,重用的組件必須有用,也就是說,是功能完備的。但另一方面,如果組件負責的職能太多也會影響重用。試想,一個文件上傳組件同時還要負責插入數據庫記錄,一個文件下載組件還要負責解析下載請求并查找要下載的文件那可真是太悲哀了,叫別人如何重用?也就是說,重用組件必須是功能完備并職責單一的,絕對不能越界參與應用業務邏輯處理,不在其位不謀其政。
另外,要重用的組件絕不能反向依賴于上層使用者。通常,重用組件以接口或類的形式提供給上層使用者調用,并放在單獨的包中。也就是說,上層使用者所在的包依賴于組件所在的包,如果組件再反向依賴于上層使用者,則兩個包之間就存在依賴環,嚴重違反了面向對象設計原則中的無環依賴原則(若想了解更多關于設計原則的內容請猛擊這里 ^_^)。試想,通用的文件上傳或下載組件如果需要讀取一個在應用程序的某個地方定義的字符串常量來確定文件上傳或下載的目錄,那是囧了。
本文件上傳和下載組件在設計時充分考慮到上述問題,只負責單純的上傳和下載工作,所需要的外部信息均通過相應方法進行設置,不會依賴于任何使用者。
二、組件的可用性如何?
答:這是一個相當有深度的問題 ^_^ 所謂可用性通俗來說就是好不好用,使用起來是否方便。作為公共組件來說,可用性是一個十分重要的質量指標。如果組件十分復雜難用,倒不如自己花點時間自己寫一個來得舒坦。本文件上傳和下載組件在設計的過程中十分注重可用性目標,操作組件的代碼行數不超過 10 行,只需幾個步驟:
-
- 生成組件實例
- 設置實例屬性
- 調用上傳/下載方法
- 處理調用結果
水吹得已經夠多了,下面讓我們來看看文件上傳和下載組件分別是如何實現的。
文件上傳
文件上傳操作通常會附加一些限制,如:文件類型、上傳文件總大小、每個文件的最大大小等。除此以外,作為一個通用組件還需要考慮更多的問題,如:支持自定義文件保存目錄、支持相對路徑和絕對路徑、支持自定義保存的文件的文件名稱、支持上傳進度反饋和上傳失敗清理等。另外,本座也不想重新造車輪,本組件是基于 Commons File Upload 實現,省卻了本座大量的工作 ^_^ 下面先從一個具體的使用例子講起:

<form action="checkupload.action" method="post" enctype="multipart/form-data">
First Name: <input type="text" name="firstName" value="丑">
<br>
Last Name: <input type="text" name="lastName" value="怪獸">
<br>
Birthday: <input type="text" name="birthday" value="1978-11-03">
<br>
Gender: 男 <input type="radio"" name="gender" value="false">
女 <input type="radio"" name="gender" value="true" checked="checked">
<br>
Working age: <select name="workingAge">
<option value="-1">-請選擇-</option>
<option value="3">三年</option>
<option value="5" selected="selected">五年</option>
<option value="10">十年</option>
<option value="20">二十年</option>
</select>
<br>
Interest: 游泳 <input type="checkbox" name="interest" value="1" checked="checked">
打球 <input type="checkbox" name="interest" value="2" checked="checked">
下棋 <input type="checkbox" name="interest" value="3">
打麻將 <input type="checkbox" name="interest" value="4">
看書 <input type="checkbox" name="interest" value="5" checked="checked">
<br>
Photo 1.1: <input type="file"" name="photo-1">
<br>
Photo 1.2: <input type="file"" name="photo-1">
<br>
Photo 2.1: <input type="file"" name="photo-2">
<br>
<br>
<input type="submit" value="確 定"> <input type="reset" value="重 置">
</form>
從上面的 HTML 代碼可以看出,表單有 6 個普通域和 3 個文件域,其中前兩個文件域的 name 屬性相同。
import com.bruce.util.BeanHelper;
import com.bruce.util.Logger;
import com.bruce.util.http.FileUploader;
import com.bruce.util.http.FileUploader.FileInfo;
import static com.bruce.util.http.FileUploader.*;
@SuppressWarnings("unused")
public class CheckUpload extends ActionSupport
{
// 上傳路徑
private static final String UPLOAD_PATH = "upload";
// 可接受的文件類型
private static final String[] ACCEPT_TYPES = {"txt", "pdf", "doc", ".Jpg", "*.zip", "*.RAR"};
// 總上傳文件大小限制
private static final long MAX_SIZE = 1024 * 1024 * 100;
// 單個傳文件大小限制
private static final long MAX_FILE_SIZE = 1024 * 1024 * 10;
@Override
public String execute()
{
// 創建 FileUploader 對象
FileUploader fu = new FileUploader(UPLOAD_PATH, ACCEPT_TYPES, MAX_SIZE, MAX_FILE_SIZE);
// 根據實際情況設置對象屬性(可選)
/*
fu.setFileNameGenerator(new FileNameGenerator()
{
@Override
public String generate(FileItem item, String suffix)
{
return String.format("%d_%d", item.hashCode(), item.get().hashCode());
}
});
fu.setServletProgressListener(new ProgressListener()
{
@Override
public void update(long pBytesRead, long pContentLength, int pItems)
{
System.out.printf("%d: length -> %d, read -> %d.\n", pItems, pContentLength, pBytesRead);
}
});
*/
// 執行上傳并獲取操作結果
Result result = fu.upload(getRequest(), getResponse());
// 檢查操作結果
if(result != FileUploader.Result.SUCCESS)
{
// 設置 request attribute
setRequestAttribute("error", fu.getCause());
// 記錄日志
Logger.exception(fu.getCause(), "upload file fail", Level.ERROR, false);
return ERROR;
}
// 通過非文件表單域創建 Form Bean
Persion persion = BeanHelper.createBean(Persion.class, fu.getParamFields());
// 圖片保存路徑的列表
List<String> photos = new ArrayList<String>();
/* 輪詢文件表單域,填充 photos */
Set<String> keys = fu.getFileFields().keySet();
for(String key : keys)
{
FileInfo[] ffs = fu.getFileFields().get(key);
for(FileInfo ff : ffs)
{
photos.add(String.format("(%s) %s%s%s", key, fu.getSavePath(), File.separator, ff.getSaveFile().getName()));
}
}
// 設置 Form Bean 的 photos 屬性
persion.setPhotos(photos);
// 設置 request attribute
setRequestAttribute("persion", persion);
return SUCCESS;
}
}
public class Persion
{
private String firstName;
private String lastName;
private Date birthday;
private boolean gender;
private int workingAge;
private int[] interest;
private List<String> photos;
}
public static class FileInfo
{
private String uploadFileName;
private File saveFile;
}
分析下上面的 Java 代碼,本例先根據保存目錄、文件大小限制和文件類型限制創建一個 FileUploader 對象,然后調用該對象的 upload() 方法執行上傳并返回操作結果,如果上傳成功則 通過 getParamFields() 方法獲取所有非文件表單域內容,并交由 BeanHelper 進行解析(若想了解更多關于 BeanHelper 的內容請猛擊這里 ^_^)創建 Form Bean,再調用 getFileFields() 方法獲取所有文件表單域的 FileInfo(FileInfo 包含上傳文件的原始名稱和被保存文件的 File 對象),最后完成 Form Bean 所有字段的填充并把 Form Bean 設置為 request 屬性。

<table border="1">
<caption>Persion Attributs</caption>
<tr><td>Name</td><td><c:out value="${persion.firstName} ${persion.lastName}"/> </td></tr>
<tr><td>Brithday</td><td><c:out value="${persion.birthday}"/> </td></tr>
<tr><td>Gender</td><td><c:out value="${persion.gender}"/> </td></tr>
<tr><td>Working Age</td><td><c:out value="${persion.workingAge}"/> </td></tr>
<tr><td>Interest</td><td><c:forEach var="its" items="${persion.interest}">
<c:out value="${its}" />
</c:forEach> </td></tr>
<tr><td>Photos</td><td><c:forEach var="p" items="${persion.photos}">
<c:out value="${p}" /><br>
</c:forEach> </td></tr>
</table>
從上面的處理結果可以看出,文件上傳組件 FileUploader 正確地處理了表單的所有文件域和非文件域名,并且,整個文件上傳操作過程非常簡單,無需用戶過多參與。下面我們來詳細看看組件的主要實現代碼:
/** 文件上傳器 */
public class FileUploader
{
/** 不限制文件上傳總大小的 Size Max 常量 */
public static final long NO_LIMIT_SIZE_MAX = -1;
/** 不限制文件上傳單個文件大小的 File Size Max 常量 */
public static final long NO_LIMIT_FILE_SIZE_MAX = -1;
/** 默認的寫文件閥值 */
public static final int DEFAULT_SIZE_THRESHOLD = DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD;
/** 默認的文件名生成器 */
public static final FileNameGenerator DEFAULT_FILE_NAME_GENERATOR = new CommonFileNameGenerator();
/** 設置上傳文件的保存路徑(不包含文件名)
*
* 文件路徑,可能是絕對路徑或相對路徑<br>
* 1) 絕對路徑:以根目錄符開始(如:'/'、'D:\'),是服務器文件系統的路徑<br>
* 2) 相對路徑:不以根目錄符開始,是相對于 WEB 應用程序 Context 的路徑,(如:mydir 是指
* '${WEB-APP-DIR}/mydir')<br>
* 3) 規則:上傳文件前會檢查該路徑是否存在,如果不存在則會嘗試生成該路徑,如果生成失敗則
* 上傳失敗并返回 {@link Result#INVALID_SAVE_PATH}
*
*/
private String savePath;
/** 文件上傳的總文件大小限制 */
private long sizeMax = NO_LIMIT_SIZE_MAX;
/** 文件上傳的單個文件大小限制 */
private long fileSizeMax = NO_LIMIT_FILE_SIZE_MAX;
/** 可接受的上傳文件類型集合,默認:不限制 */
private Set<String> acceptTypes = new LStrSet();
/** 非文件表單域的映射 */
private Map<String, String[]> paramFields = new HashMap<String, String[]>();
/** 文件表單域的映射 */
private Map<String, FileInfo[]> fileFields = new HashMap<String, FileInfo[]>();
/** 文件名生成器 */
private FileNameGenerator fileNameGenerator = DEFAULT_FILE_NAME_GENERATOR;
// commons file upload 相關屬性
private int factorySizeThreshold = DEFAULT_SIZE_THRESHOLD;
private String factoryRepository;
private FileCleaningTracker factoryCleaningTracker;
private String servletHeaderencoding;
private ProgressListener servletProgressListener;
/** 文件上傳失敗的原因(文件上傳失敗時使用) */
private Throwable cause;
/** 執行上傳
*
* @param request : {@link HttpServletRequest} 對象
* @param response : {@link HttpServletResponse} 對象
*
* @return : 成功:返回 {@link Result#SUCCESS} ,失?。悍祷仄渌Y果,
* 失敗原因通過 {@link FileUploader#getCause()} 獲取
*
*/
@SuppressWarnings("unchecked")
public Result upload(HttpServletRequest request, HttpServletResponse response)
{
reset();
// 獲取上傳目錄絕對路徑
String absolutePath = getAbsoluteSavePath(request);
if(absolutePath == null)
{
cause = new FileNotFoundException(String.format("path '%s' not found or is not directory", savePath));
return Result.INVALID_SAVE_PATH;
}
ServletFileUpload sfu = getFileUploadComponent();
List<FileItemInfo> fiis = new ArrayList<FileItemInfo>();
List<FileItem> items = null;
Result result = Result.SUCCESS;
// 獲取文件名生成器
String encoding = servletHeaderencoding != null ? servletHeaderencoding : request.getCharacterEncoding();
FileNameGenerator fnGenerator = fileNameGenerator != null ? fileNameGenerator : DEFAULT_FILE_NAME_GENERATOR;
try
{
// 執行上傳操作
items = (List<FileItem>)sfu.parseRequest(request);
}
catch (FileUploadException e)
{
cause = e;
if(e instanceof FileSizeLimitExceededException) result = Result.FILE_SIZE_EXCEEDED;
else if(e instanceof SizeLimitExceededException) result = Result.SIZE_EXCEEDED;
else if(e instanceof InvalidContentTypeException) result = Result.INVALID_CONTENT_TYPE;
else if(e instanceof IOFileUploadException) result = Result.FILE_UPLOAD_IO_EXCEPTION;
else result = Result.OTHER_PARSE_REQUEST_EXCEPTION;
}
if(result == Result.SUCCESS)
{
// 解析所有表單域
result = parseFileItems(items, fnGenerator, absolutePath, encoding, fiis);
if(result == Result.SUCCESS)
// 保存文件
result = writeFiles(fiis);
}
return result;
}
// 解析所有表單域
private Result parseFileItems(List<FileItem> items, FileNameGenerator fnGenerator, String absolutePath, String encoding, List<FileItemInfo> fiis)
{
for(FileItem item : items)
{
if(item.isFormField())
// 解析非文件表單域
parseFormField(item, encoding);
else
{
if(item.getSize() == 0)
continue;
// 解析文件表單域
Result result = parseFileField(item, absolutePath, fnGenerator, fiis);
if(result != Result.SUCCESS)
{
reset();
cause = new InvalidParameterException(String.format("file '%s' not accepted", item.getName()));
return result;
}
}
}
return Result.SUCCESS;
}
// 解析文件表單域
private Result parseFileField(FileItem item, String absolutePath, FileNameGenerator fnGenerator, List<FileItemInfo> fiis)
{
String suffix = null;
String uploadFileName = item.getName();
boolean isAcceptType = acceptTypes.isEmpty();
if(!isAcceptType)
{
suffix = null;
int stuffPos = uploadFileName.lastIndexOf(".");
if(stuffPos != -1)
{
suffix = uploadFileName.substring(stuffPos, uploadFileName.length()).toLowerCase();
isAcceptType = acceptTypes.contains(suffix);
}
}
if(!isAcceptType)
return Result.INVALID_FILE_TYPE;
// 通過文件名生成器獲取文件名
String saveFileName = fnGenerator.generate(item, suffix);
if(!saveFileName.endsWith(suffix))
saveFileName += suffix;
String fullFileName = absolutePath + File.separator + saveFileName;
File saveFile = new File(fullFileName);
FileInfo info = new FileInfo(uploadFileName, saveFile);
// 添加表單域文件信息
fiis.add(new FileItemInfo(item, saveFile));
addFileField(item.getFieldName(), info);
return Result.SUCCESS;
}
private void parseFormField(FileItem item, String encoding)
{
String name = item.getFieldName();
String value = item.getString();
// 字符串編碼轉換
if(!GeneralHelper.isStrEmpty(value) && encoding != null)
{
try
{
value = new String(value.getBytes("ISO-8859-1"), encoding);
}
catch(UnsupportedEncodingException e)
{
}
}
// 添加表單域名/值映射
addParamField(name, value);
}
/** 文件名生成器接口
*
* 每次保存一個上傳文件前都需要調用該接口的 {@link FileNameGenerator#generate} 方法生成要保存的文件名
*
*/
public static interface FileNameGenerator
{
/** 文件名生成方法
*
* @param item : 上傳文件對應的 {@link FileItem} 對象
* @param suffix : 上傳文件的后綴名
*
*/
String generate(FileItem item, String suffix);
}
/** 默認通用文件名生成器
*
* 實現 {@link FileNameGenerator} 接口,根據序列值和時間生成唯一文件名
*
*/
public static class CommonFileNameGenerator implements FileNameGenerator
{
private static final int MAX_SERIAL = 999999;
private static final AtomicInteger atomic = new AtomicInteger();
private static int getNextInteger()
{
int value = atomic.incrementAndGet();
if(value >= MAX_SERIAL)
atomic.set(0);
return value;
}
/** 根據序列值和時間生成 'XXXXXX_YYYYYYYYYYYYY' 格式的唯一文件名 */
@Override
public String generate(FileItem item, String suffix)
{
int serial = getNextInteger();
long millsec = System.currentTimeMillis();
return String.format("%06d_%013d", serial, millsec);
}
}
/** 上傳文件信息結構體 */
public static class FileInfo
{
private String uploadFileName;
private File saveFile;
// getters and setters ...
}
private class FileItemInfo
{
FileItem item;
File file;
// getters and setters ...
}
/** 文件上傳結果枚舉值 */
public static enum Result
{
/** 成功 */
SUCCESS,
/** 失?。何募偞笮〕^限制 */
SIZE_EXCEEDED,
/** 失敗:單個文件大小超過限制 */
FILE_SIZE_EXCEEDED,
/** 失?。赫埱蟊韱晤愋筒徽_ */
INVALID_CONTENT_TYPE,
/** 失敗:文件上傳 IO 錯誤 */
FILE_UPLOAD_IO_EXCEPTION,
/** 失?。航馕錾蟼髡埱笃渌惓?*/
OTHER_PARSE_REQUEST_EXCEPTION,
/** 失?。何募愋筒徽_ */
INVALID_FILE_TYPE,
/** 失?。何募懭胧?*/
WRITE_FILE_FAIL,
/** 失?。何募谋4媛窂讲徽_ */
INVALID_SAVE_PATH;
}
}
具體的實現細節就不多描述了,大家可以下載附件慢慢研究。這里只說明兩點:
1、應用可以實現自己的 FileNameGenerator 類替代默認的文件名生成器。
2、上傳操作通過 FileUploader.Result 返回結果,并沒有采用拋出異常的方式,因為本座認為在這里采用異常方式報告結果其實并不方便使用;另一方面,程序可以通過 getCause() 獲取詳細的錯誤信息。
文件下載
相對于文件上傳,文件下載則簡單很多,主要實現流程是根據文件名找到實際文件,并利用 Java 的相關類對 I/O 流進行讀寫。下面先看看一個使用示例:
import static com.bruce.util.http.FileDownloader.*;
public class TestDownload extends ActionSupport
{
// 絕對路徑
private static final String ABSOLUTE_PATH = "/Server/apache-tomcat-6.0.32/webapps/portal/download/下載測試 - 文本文件.txt";
// 相對路徑
private static final String RELATE_PATH = "download/下載測試 - 項目框架.jpg";
@Override
public String execute()
{
int type = getIntParam("type", 1);
String filePath = (type == 1 ? ABSOLUTE_PATH : RELATE_PATH);
// 創建 FileDownloader 對象
FileDownloader fdl = new FileDownloader(filePath);
// 執行下載
Result result = fdl.downLoad(getRequest(), getResponse());
// 檢查下載結果
if(result != Result.SUCCESS)
{
// 記錄日志
Logger.exception(fdl.getCause(), String.format("download file '%s' fail", fdl.getFilePath()), Level.ERROR, false);
}
return NONE;
}
}
從這個示例可以看出,文件下載組件的使用方法更簡單,因為它不需要對下載結果進行很多處理。可以看出該組件也支持相對路徑和絕對路徑。下面我們來詳細看看組件的主要實現代碼:
/** 文件下載器 */
public class FileDownloader
{
/** 默認字節交換緩沖區大小 */
public static final int DEFAULT_BUFFER_SIZE = 1024 * 4;
/** 下載文件的默認 Mime Type */
public static final String DEFAULT_CONTENT_TYPE = "application/force-download";
/** 設置下載文件的路徑(包含文件名)
*
* filePath : 文件路徑,可能是絕對路徑或相對路徑<br>
* 1) 絕對路徑:以根目錄符開始(如:'/'、'D:\'),是服務器文件系統的路徑<br>
* 2) 相對路徑:不以根目錄符開始,是相對于 WEB 應用程序 Context 的路徑,(如:mydir/myfile 是指 '${WEB-APP-DIR}/mydir/myfile')
*/
private String filePath;
/** 顯示在瀏覽器的下載對話框中的文件名稱,默認與 filePath 參數中的文件名一致 */
private String saveFileName;
/** 下載文件的 Mime Type,默認:{@link FileDownloader#DEFAULT_CONTENT_TYPE} */
private String contentType = DEFAULT_CONTENT_TYPE;
/** 字節緩沖區大小,默認:{@link FileDownloader#DEFAULT_CONTENT_TYPE} */
private int bufferSize = DEFAULT_BUFFER_SIZE;
/** 獲取文件下載失敗的原因(文件下載失敗時使用) */
private Throwable cause;
/** 執行下載
*
* @param request : {@link HttpServletRequest} 對象
* @param response : {@link HttpServletResponse} 對象
*
* @return : 成功:返回 {@link Result#SUCCESS} ,失?。悍祷仄渌Y果,
* 失敗原因通過 {@link FileDownloader#getCause()} 獲取
*
*/
public Result downLoad(HttpServletRequest request, HttpServletResponse response)
{
reset();
try
{
// 獲取要下載的文件的 File 對象
File file = getFile(request);
// 執行下載操作
downLoadFile(request, response, file);
}
catch(Exception e)
{
cause = e;
if(e instanceof FileNotFoundException) return Result.FILE_NOT_FOUND;
if(e instanceof IOException) return Result.READ_WRITE_FAIL;
return Result.UNKNOWN_EXCEPTION;
}
return Result.SUCCESS;
}
// 執行下載操作
private void downLoadFile(HttpServletRequest request, HttpServletResponse response, File file) throws IOException
{
String fileName = new String(saveFileName.getBytes(), "ISO-8859-1");
// 解析 HTTP 請求頭,獲取文件的讀取范圍
Range<Integer> range = parseDownloadRange(request);
int length = (int)file.length();
int begin = 0;
int end = length - 1;
// 設置 HTTP 響應頭
response.setContentType(contentType);
response.setContentLength(length);
response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
// 確定文件的讀取范圍(用于斷點續傳)
if(range != null)
{
if(range.getBegin() != null)
{
begin = range.getBegin();
if(range.getEnd() != null)
end = range.getEnd();
}
else
{
if(range.getEnd() != null)
begin = end + range.getEnd() + 1;
}
String contentRange = String.format("bytes %d-%d/%d", begin, end, length);
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Range", contentRange);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
}
// 實際執行下載操作
doDownloadFile(response, file, begin, end);
}
// 實際執行下載操作
private void doDownloadFile(HttpServletResponse response, File file, int begin, int end) throws IOException
{
InputStream is = null;
OutputStream os = null;
try
{
byte[] b = new byte[bufferSize];
is = new BufferedInputStream(new FileInputStream(file));
os = new BufferedOutputStream(response.getOutputStream());
// 跳過已下載的文件內容
is.skip(begin);
// I/O 讀寫
for(int i, left = end - begin + 1; left > 0 && ((i = is.read(b, 0, Math.min(b.length, left))) != -1); left -= i)
os.write(b, 0, i);
os.flush();
}
finally
{
if(is != null) {try{is.close();} catch(IOException e) {}}
if(os != null) {try{os.close();} catch(IOException e) {}}
}
}
/** 文件下載結果枚舉值 */
public static enum Result
{
/** 成功 */
SUCCESS,
/** 失?。何募淮嬖?*/
FILE_NOT_FOUND,
/** 失敗:讀寫操作失敗 */
READ_WRITE_FAIL,
/** 失敗:未知異常 */
UNKNOWN_EXCEPTION;
}
}
實現文件下載的代碼也相當簡單,另外,本組件支持斷點續傳。大家可以下載附件慢慢研究。
(下載源代碼請輕點這里 ^_^)
原文出處:怪獸的博客 怪獸的微博 怪獸樂園Q群