随着3.0版本的发布,文件上传终于成为Servlet规范的一项内置特性,不再依赖于像 Commons FileUpload 之类组件,因此在服务端进行文件上传编程变得不费吹灰之力.
要上传文件, 必须利用 multipart/form-data
设置HTML表单的 enctype 属性,且 method 必须为 POST
:
<form action="simple_file_upload_servlet.do" method="POST" enctype="multipart/form-data"> <table align="center" border="1" width="50%"> <tr> <td>Author:</td> <td><input type="text" name="author"></td> </tr> <tr> <td>Select file to Upload:</td> <td><input type="file" name="file"></td> </tr> <tr> <td><input type="submit" value="上传"></td> </tr> </table> </form>
服务端Servlet主要围绕着 @MultipartConfig
注解和 Part
接口:
处理上传文件的Servlet必须用 @MultipartConfig
注解标注:
@MultipartConfig属性 | 描述 |
---|---|
fileSizeThreshold | The size threshold after which the file will be written to disk |
location | The directory location where files will be stored |
maxFileSize | The maximum size allowed for uploaded files. |
maxRequestSize | The maximum size allowed for multipart/form-data requests |
在一个由多部件组成的请求中, 每一个表单域(包括非文件域), 都会被封装成一个 Part
, HttpServletRequest
中提供如下两个方法获取封装好的 Part
:
HttpServletRequest | 描述 |
---|---|
Part getPart(String name) | Gets the Part with the given name. |
Collection<Part> getParts() | Gets all the Part components of this request, provided that it is of type multipart/form-data. |
Part
中提供了如下常用方法来获取/操作上传的文件/数据:
Part | 描述 |
---|---|
InputStream getInputStream() | Gets the content of this part as an InputStream |
void write(String fileName) | A convenience method to write this uploaded item to disk. |
String getSubmittedFileName() | Gets the file name specified by the client(需要有Tomcat 8.x 及以上版本支持) |
long getSize() | Returns the size of this fille. |
void delete() | Deletes the underlying storage for a file item, including deleting any associated temporary disk file. |
String getName() | Gets the name of this part |
String getContentType() | Gets the content type of this part. |
Collection<String> getHeaderNames() | Gets the header names of this Part. |
String getHeader(String name) | Returns the value of the specified mime header as a String. |
通过抓包获取到客户端上传文件的数据格式:
------WebKitFormBoundaryXJ6TxfJ9PX5hJHGh Content-Disposition: form-data; name="author" feiqing ------WebKitFormBoundaryXJ6TxfJ9PX5hJHGh Content-Disposition: form-data; name="file"; filename="memcached.txt" Content-Type: text/plain ------WebKitFormBoundaryXJ6TxfJ9PX5hJHGh--
<input type="text"/>
),将只包含一个请求头 Content-Disposition
. B. 如果HTML表单输入项为文件( <input type="file"/>
), 则包含两个头:
Content-Disposition
与 Content-Type
.
在Servlet中处理上传文件时, 需要:
<code>- 通过查看是否存在`Content-Type`标头, 检验一个Part是封装的普通表单域,还是文件域. - 若有`Content-Type`存在, 但文件名为空, 则表示没有选择要上传的文件. - 如果有文件存在, 则可以调用`write()`方法来写入磁盘, 调用同时传递一个绝对路径, 或是相对于`@MultipartConfig`注解的`location`属性的相对路径. </code>
/** * @author jifang. * @since 2016/5/8 16:27. */ @MultipartConfig @WebServlet(name = "SimpleFileUploadServlet", urlPatterns = "/simple_file_upload_servlet.do") public class SimpleFileUploadServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html;charset=UTF-8"); PrintWriter writer = response.getWriter(); Part file = request.getPart("file"); if (!isFileValid(file)) { writer.print("<h1>请确认上传文件是否正确!"); } else { String fileName = file.getSubmittedFileName(); String saveDir = getServletContext().getRealPath("/WEB-INF/files/"); mkdirs(saveDir); file.write(saveDir + fileName); writer.print("<h3>Uploaded file name: " + fileName); writer.print("<h3>Size: " + file.getSize()); writer.print("<h3>Author: " + request.getParameter("author")); } } private void mkdirs(String saveDir) { File dir = new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } } private boolean isFileValid(Part file) { // 上传的并非文件 if (file.getContentType() == null) { return false; } // 没有选择任何文件 else if (Strings.isNullOrEmpty(file.getSubmittedFileName())) { return false; } return true; } }
WEB-INF
/WEB-INF/
目录下的资源无法在浏览器地址栏直接访问, 利用这一特点可将某些受保护资源存放在 WEB-INF 目录下, 禁止用户直接访问(如用户上传的可执行文件,如JSP等),以防被恶意执行, 造成服务器信息泄露等危险. getServletContext().getRealPath("/WEB-INF/")
文件名乱码
当文件名包含中文时,可能会出现乱码,其解决方案与 POST
相同:
request.setCharacterEncoding("UTF-8");
避免文件同名如果上传同名文件,会造成文件覆盖.因此可以为每份文件生成一个唯一ID,然后连接原始文件名:
private String generateUUID() { return UUID.randomUUID().toString().replace("-", "_"); }
目录打散
如果一个目录下存放的文件过多, 会导致文件检索速度下降,因此需要将文件打散存放到不同目录中, 在此我们采用Hash打散法(根据文件名生成Hash值, 取Hash值的前两个字符作为二级目录名), 将文件分布到一个二级目录中:
private String generateTwoLevelDir(String destFileName) { String hash = Integer.toHexString(destFileName.hashCode()); return String.format("%s/%s", hash.charAt(0), hash.charAt(1)); }
采用Hash打散的好处是:在根目录下最多生成16个目录,而每个子目录下最多再生成16个子子目录,即一共256个目录,且分布较为均匀.
需求: 提供上传图片功能, 为其生成外链, 并提供下载功能(见下)
<html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>IFS</title> </head> <body> <form action="ifs_upload.action" method="POST" enctype="multipart/form-data"> <table align="center" border="1" width="50%"> <tr> <td>Select A Image to Upload:</td> <td><input type="file" name="image"></td> </tr> <tr> <td> </td> <td><input type="submit" value="上传"></td> </tr> </table> </form> </body> </html>
@MultipartConfig @WebServlet(name = "ImageFileUploadServlet", urlPatterns = "/ifs_upload.action") public class ImageFileUploadServlet extends HttpServlet { private Set<String> imageSuffix = new HashSet<>(); private static final String SAVE_ROOT_DIR = "/images"; { imageSuffix.add(".jpg"); imageSuffix.add(".png"); imageSuffix.add(".jpeg"); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); PrintWriter writer = response.getWriter(); Part image = request.getPart("image"); String fileName = getFileName(image); if (isFileValid(image, fileName) && isImageValid(fileName)) { String destFileName = generateDestFileName(fileName); String twoLevelDir = generateTwoLevelDir(destFileName); // 保存文件 String saveDir = String.format("%s/%s/", getServletContext().getRealPath(SAVE_ROOT_DIR), twoLevelDir); makeDirs(saveDir); image.write(saveDir + destFileName); // 生成外链 String ip = request.getLocalAddr(); int port = request.getLocalPort(); String path = request.getContextPath(); String urlPrefix = String.format("http://%s:%s%s", ip, port, path); String urlSuffix = String.format("%s/%s/%s", SAVE_ROOT_DIR, twoLevelDir, destFileName); String url = urlPrefix + urlSuffix; String result = String.format("<a href=%s>%s</a><hr/><a href=ifs_download.action?location=%s>下载</a>", url, url, saveDir + destFileName); writer.print(result); } else { writer.print("Error : Image Type Error"); } } /** * 校验文件表单域有效 * * @param file * @param fileName * @return */ private boolean isFileValid(Part file, String fileName) { // 上传的并非文件 if (file.getContentType() == null) { return false; } // 没有选择任何文件 else if (Strings.isNullOrEmpty(fileName)) { return false; } return true; } /** * 校验文件后缀有效 * * @param fileName * @return */ private boolean isImageValid(String fileName) { for (String suffix : imageSuffix) { if (fileName.endsWith(suffix)) { return true; } } return false; } /** * 加速图片访问速度, 生成两级存放目录 * * @param destFileName * @return */ private String generateTwoLevelDir(String destFileName) { String hash = Integer.toHexString(destFileName.hashCode()); return String.format("%s/%s", hash.charAt(0), hash.charAt(1)); } private String generateUUID() { return UUID.randomUUID().toString().replace("-", "_"); } private String generateDestFileName(String fileName) { String destFileName = generateUUID(); int index = fileName.lastIndexOf("."); if (index != -1) { destFileName += fileName.substring(index); } return destFileName; } private String getFileName(Part part) { String[] elements = part.getHeader("content-disposition").split(";"); for (String element : elements) { if (element.trim().startsWith("filename")) { return element.substring(element.indexOf("=") + 1).trim().replace("/"", ""); } } return null; } private void makeDirs(String saveDir) { File dir = new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } } }
由于 getSubmittedFileName()
方法需要有Tomcat 8.X以上版本的支持, 因此为了通用期间, 我们自己解析 content-disposition
请求头, 获取filename.
文件下载是向客户端响应二进制数据(而非字符),浏览器不会直接显示这些内容,而是会弹出一个下载框, 提示下载信息.
为了将资源发送给浏览器, 需要在Servlet中完成以下工作:
Content-Type
响应头来规定响应体的MIME类型, 如 image/pjpeg 、 application/octet-stream ; Content-Disposition
响应头,赋值为 attachment;filename=xxx.yyy
, 设置文件名; response.getOutputStream()
给浏览器发送二进制数据; 当文件名包含中文时( attachment;filename=文件名.后缀名
),在下载框中会出现乱码, 需要对文件名编码后在发送, 但不同的浏览器接收的编码方式不同:
<code> * FireFox: Base64编码 * 其他大部分Browser: URL编码 </code>
因此最好将其封装成一个通用方法:
private String filenameEncoding(String filename, HttpServletRequest request) throws IOException { // 根据浏览器信息判断 if (request.getHeader("User-Agent").contains("Firefox")) { filename = String.format("=?utf-8?B?%s?=", BaseEncoding.base64().encode(filename.getBytes("UTF-8"))); } else { filename = URLEncoder.encode(filename, "utf-8"); } return filename; }
/** * @author jifang. * @since 2016/5/9 17:50. */ @WebServlet(name = "ImageFileDownloadServlet", urlPatterns = "/ifs_download.action") public class ImageFileDownloadServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("application/octet-stream"); String fileLocation = request.getParameter("location"); String fileName = fileLocation.substring(fileLocation.lastIndexOf("/") + 1); response.setHeader("Content-Disposition", "attachment;filename=" + filenameEncoding(fileName, request)); ByteStreams.copy(new FileInputStream(fileLocation), response.getOutputStream()); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req, resp); } }
Servlet
/ Filter
默认会一直占用请求处理线程, 直到它完成任务.如果任务耗时长久, 且并发用户请求量大, Servlet容器将会遇到超出线程数的风险.
Servlet 3.0 中新增了一项特性, 用来处理异步操作. 当 Servlet
/ Filter
应用程序中有一个/多个长时间运行的任务时, 你可以选择将任务分配给一个新的线程, 从而将当前请求处理线程返回到线程池中,释放线程资源,准备为下一个请求服务.
异步支持
@WebServlet
/ @WebFilter
注解提供了新的 asyncSupport
属性:
@WebFilter(asyncSupported = true) @WebServlet(asyncSupported = true)
同样部署描述符中也添加了 <async-supportted/>
标签:
<servlet> <servlet-name>HelloServlet</servlet-name> <servlet-class>com.fq.web.servlet.HelloServlet</servlet-class> <async-supported>true</async-supported> </servlet>
Servlet/Filter
支持异步处理的 Servlet
/ Filter
可以通过在 ServletRequest
中调用 startAsync()
方法来启动新线程:
ServletRequest | 描述 |
---|---|
AsyncContext startAsync() | Puts this request into asynchronous mode, and initializes its AsyncContext with the original (unwrapped) ServletRequest and ServletResponse objects. |
AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) | Puts this request into asynchronous mode, and initializes its AsyncContext with the given request and response objects. |
注意:
1. 只能将原始的 ServletRequest
/ ServletResponse
或其包装器(Wrapper/Decorator,详见 Servlet – Listener、Filter、Decorator )传递给第二个 startAsync()
方法.
2. 重复调用 startAsync()
方法会返回相同的 AsyncContext
实例, 如果在不支持异步处理的 Servlet
/ Filter
中调用, 会抛出 java.lang.IllegalStateException
异常.
3. AsyncContext
的 start()
方法不会造成方法阻塞.
这两个方法都返回 AsyncContext
实例, AsyncContext
中提供了如下常用方法:
AsyncContext | 描述 |
---|---|
void start(Runnable run) | Causes the container to dispatch a thread, possibly from a managed thread pool, to run the specified Runnable. |
void dispatch(String path) | Dispatches the request and response objects of this AsyncContext to the given path. |
void dispatch(ServletContext context, String path) | Dispatches the request and response objects of this AsyncContext to the given path scoped to the given context. |
void addListener(AsyncListener listener) | Registers the given AsyncListener with the most recent asynchronous cycle that was started by a call to one of the ServletRequest.startAsync() methods. |
ServletRequest getRequest() | Gets the request that was used to initialize this AsyncContext by calling ServletRequest.startAsync() or ServletRequest.startAsync(ServletRequest, ServletResponse). |
ServletResponse getResponse() | Gets the response that was used to initialize this AsyncContext by calling ServletRequest.startAsync() or ServletRequest.startAsync(ServletRequest, ServletResponse). |
boolean hasOriginalRequestAndResponse() | Checks if this AsyncContext was initialized with the original or application-wrapped request and response objects. |
void setTimeout(long timeout) | Sets the timeout (in milliseconds) for this AsyncContext. |
在异步 Servlet
/ Filter
中需要完成以下工作, 才能真正达到异步的目的:
AsyncContext
的 start()
方法, 传递一个执行长时间任务的 Runnable
; Runnable
内调用 AsyncContext
的 complete()
方法或 dispatch()
方法 在前面的图片存储服务器中, 如果上传图片过大, 可能会耗时长久,为了提升服务器性能, 可将其改造为异步上传(其改造成本较小):
@Override protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { final AsyncContext asyncContext = request.startAsync(); asyncContext.start(new Runnable() { @Override public void run() { try { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); PrintWriter writer = response.getWriter(); Part image = request.getPart("image"); final String fileName = getFileName(image); if (isFileValid(image, fileName) && isImageValid(fileName)) { String destFileName = generateDestFileName(fileName); String twoLevelDir = generateTwoLevelDir(destFileName); // 保存文件 String saveDir = String.format("%s/%s/", getServletContext().getRealPath(SAVE_ROOT_DIR), twoLevelDir); makeDirs(saveDir); image.write(saveDir + destFileName); // 生成外链 String ip = request.getLocalAddr(); int port = request.getLocalPort(); String path = request.getContextPath(); String urlPrefix = String.format("http://%s:%s%s", ip, port, path); String urlSuffix = String.format("%s/%s/%s", SAVE_ROOT_DIR, twoLevelDir, destFileName); String url = urlPrefix + urlSuffix; String result = String.format("<a href=%s>%s</a><hr/><a href=ifs_download.action?location=%s>下载</a>", url, url, saveDir + destFileName); writer.print(result); } else { writer.print("Error : Image Type Error"); } asyncContext.complete(); } catch (ServletException | IOException e) { LOGGER.error("error: ", e); } } }); }
注意: Servlet异步支持只适用于长时间运行,且想让用户知道执行结果的任务. 如果只有长时间, 但用户不需要知道处理结果,那么只需提供一个 Runnable
提交给 Executor
, 并立即返回即可.
Servlet 3.0 还新增了一个 AsyncListener
接口, 以便通知用户在异步处理期间发生的事件, 该接口会在异步操作的 启动 / 完成 / 失败 / 超时 情况下调用其对应方法:
/** * @author jifang. * @since 2016/5/10 17:33. */ public class ImageUploadListener implements AsyncListener { @Override public void onComplete(AsyncEvent event) throws IOException { System.out.println("onComplete..."); } @Override public void onTimeout(AsyncEvent event) throws IOException { System.out.println("onTimeout..."); } @Override public void onError(AsyncEvent event) throws IOException { System.out.println("onError..."); } @Override public void onStartAsync(AsyncEvent event) throws IOException { System.out.println("onStartAsync..."); } }
与其他监听器不同, 他没有 @WebListener
标注 AsyncListener
的实现, 因此必须对有兴趣收到通知的每个 AsyncContext
都手动注册一个 AsyncListener
:
asyncContext.addListener(new ImageUploadListener());
动态注册是Servlet 3.0新特性,它不需要重新加载应用便可安装新的 Web对象 ( Servlet
/ Filter
/ Listener
等).
为了使动态注册成为可能, ServletContext
接口添加了如下方法用于 创建/添加 Web对象:
ServletContext | 描述 |
---|---|
Create | |
<T extends Servlet> T createServlet(Class<T> clazz) | Instantiates the given Servlet class. |
<T extends Filter> T createFilter(Class<T> clazz) | Instantiates the given Filter class. |
<T extends EventListener> T createListener(Class<T> clazz) | Instantiates the given EventListener class. |
Add | |
ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet) | Registers the given servlet instance with this ServletContext under the given servletName. |
FilterRegistration.Dynamic addFilter(String filterName, Filter filter) | Registers the given filter instance with this ServletContext under the given filterName. |
<T extends EventListener> void addListener(T t) | Adds the given listener to this ServletContext. |
Create & And | |
ServletRegistration.Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass) | Adds the servlet with the given name and class type to this servlet context. |
ServletRegistration.Dynamic addServlet(String servletName, String className) | Adds the servlet with the given name and class name to this servlet context. |
FilterRegistration.Dynamic addFilter(String filterName, Class<? extends Filter> filterClass) | Adds the filter with the given name and class type to this servlet context. |
FilterRegistration.Dynamic addFilter(String filterName, String className) | Adds the filter with the given name and class name to this servlet context. |
void addListener(Class<? extends EventListener> listenerClass) | Adds a listener of the given class type to this ServletContext. |
void addListener(String className) | Adds the listener with the given class name to this ServletContext. |
其中 addServlet()
/ addFilter()
方法的返回值是 ServletRegistration.Dynamic
/ FilterRegistration.Dynamic
,他们都是 Registration.Dynamic
的子接口,用于动态配置 Servlet
/ Filter
实例.
动态注册DynamicServlet, 注意: 并未使用 web.xml 或 @WebServlet
静态注册 DynamicServlet
实例, 而是用 DynRegListener
在服务器启动时动态注册.
/** * @author jifang. * @since 2016/5/13 16:41. */ public class DynamicServlet extends HttpServlet { private String dynamicName; @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.getWriter().print("<h1>DynamicServlet, MyDynamicName: " + getDynamicName() + "</h1>"); } public String getDynamicName() { return dynamicName; } public void setDynamicName(String dynamicName) { this.dynamicName = dynamicName; } }
@WebListener public class DynRegListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { ServletContext context = sce.getServletContext(); DynamicServlet servlet; try { servlet = context.createServlet(DynamicServlet.class); } catch (ServletException e) { servlet = null; } if (servlet != null) { servlet.setDynamicName("Hello fQ Servlet"); ServletRegistration.Dynamic dynamic = context.addServlet("dynamic_servlet", servlet); dynamic.addMapping("/dynamic_servlet.do"); } } @Override public void contextDestroyed(ServletContextEvent sce) { } }
在使用类似SpringMVC这样的MVC框架时,需要首先注册 DispatcherServlet
到 web.xml 以完成URL的转发映射:
<!-- 配置SpringMVC --> <servlet> <servlet-name>mvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/mvc-servlet.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>mvc</servlet-name> <url-pattern>*.do</url-pattern> </servlet-mapping>
在Servlet 3.0中,通过 Servlet容器初始化 ,可以自动完成Web对象的首次注册,因此可以省略这个步骤.
容器初始化的核心是 javax.servlet.ServletContainerInitializer
接口,他只包含一个方法:
ServletContainerInitializer | 描述 |
---|---|
void onStartup(Set<Class<?>> c, ServletContext ctx) | Notifies this ServletContainerInitializer of the startup of the application represented by the given ServletContext. |
在执行任何 ServletContext
监听器之前, 由Servlet容器自动调用 onStartup()
方法.
注意: 任何实现了 ServletContainerInitializer
的类必须使用 @HandlesTypes
注解标注, 以声明该初始化程序可以处理这些类型的类.
利用Servlet容器初始化, SpringMVC可实现容器的零配置注册.
@HandlesTypes(WebApplicationInitializer.class) public class SpringServletContainerInitializer implements ServletContainerInitializer { @Override public void onStartup(Set<Class<?>> webAppInitializerClasses, ServletContext servletContext) throws ServletException { List<WebApplicationInitializer> initializers = new LinkedList<WebApplicationInitializer>(); if (webAppInitializerClasses != null) { for (Class<?> waiClass : webAppInitializerClasses) { // Be defensive: Some servlet containers provide us with invalid classes, // no matter what @HandlesTypes says... if (!waiClass.isInterface() && !Modifier.isAbstract(waiClass.getModifiers()) && WebApplicationInitializer.class.isAssignableFrom(waiClass)) { try { initializers.add((WebApplicationInitializer) waiClass.newInstance()); } catch (Throwable ex) { throw new ServletException("Failed to instantiate WebApplicationInitializer class", ex); } } } } if (initializers.isEmpty()) { servletContext.log("No Spring WebApplicationInitializer types detected on classpath"); return; } AnnotationAwareOrderComparator.sort(initializers); servletContext.log("Spring WebApplicationInitializers detected on classpath: " + initializers); for (WebApplicationInitializer initializer : initializers) { initializer.onStartup(servletContext); } } }
SpringMVC为 ServletContainerInitializer
提供了实现类 SpringServletContainerInitializer
通过查看源代码可以知道,我们只需提供 WebApplicationInitializer
的实现类到classpath下, 即可完成对所需 Servlet
/ Filter
/ Listener
的注册.
public interface WebApplicationInitializer { void onStartup(ServletContext servletContext) throws ServletException; }
详细可参考 springmvc基于java config的实现
org.springframework.web.SpringServletContainerInitializer
元数据文件<strong>javax.servlet.ServletContainerInitializer</strong>只有一行内容(即实现了<code style="font-style: inherit;">ServletContainerInitializer</code>类的全限定名),该文本文件必须放在jar包的<strong>META-INF/services</strong>目录下.