Servlet – Upload、Download、Async、动态注册

Upload-上传
随着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--
可以看到:
A. 如果HTML表单输入项为文本(<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>
SimpleFileUploadServlet

/**

getServletContext().getRealPath("/WEB-INF/")
文件名乱码
当文件名包含中文时,可能会出现乱码,其解决方案与POST相同:
1
request.setCharacterEncoding("UTF-8");
避免文件同名
如果上传同名文件,会造成文件覆盖.因此可以为每份文件生成一个唯一ID,然后连接原始文件名:

private String generateUUID() {
return UUID.randomUUID().toString().replace("-", "_");
}
目录打散
如果一个目录下存放的文件过多, 会导致文件检索速度下降,因此需要将文件打散存放到不同目录中, 在此我们采用Hash打散法(根据文件名生成Hash值, 取Hash值的前两个字符作为二级目录名), 将文件分布到一个二级目录中:
1
2
3
4
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>
br/>服务端
@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.

Download-下载
文件下载是向客户端响应二进制数据(而非字符),浏览器不会直接显示这些内容,而是会弹出一个下载框, 提示下载信息.

为了将资源发送给浏览器, 需要在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;
}
示例-IFS下载功能
/**

  • @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);
    }
    }
    Async-异步处理
    Servlet/Filter默认会一直占用请求处理线程, 直到它完成任务.如果任务耗时长久, 且并发用户请求量大, Servlet容器将会遇到超出线程数的风险.

Servlet 3.0 中新增了一项特性, 用来处理异步操作. 当Servlet/Filter应用程序中有一个/多个长时间运行的任务时, 你可以选择将任务分配给一个新的线程, 从而将当前请求处理线程返回到线程池中,释放线程资源,准备为下一个请求服务.

异步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() {
br/>@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, 并立即返回即可.

AsyncListener
Servlet 3.0 还新增了一个AsyncListener接口, 以便通知用户在异步处理期间发生的事件, 该接口会在异步操作的启动/完成/失败/超时情况下调用其对应方法:

ImageUploadListener
/**

  • @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:

1
asyncContext.addListener(new ImageUploadListener());
动态注册
动态注册是Servlet 3.0新特性,它不需要重新加载应用便可安装新的Web对象(Servlet/Filter/Listener等).

API支持
为了使动态注册成为可能, 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
动态注册DynamicServlet, 注意: 并未使用web.xml或@WebServlet静态注册DynamicServlet实例, 而是用DynRegListener在服务器启动时动态注册.

DynamicServlet

/**

  • @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;
    }
    }
    DynRegListener

@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的转发映射:

mvc org.springframework.web.servlet.DispatcherServlet contextConfigLocation classpath:spring/mvc-servlet.xml 1 mvc *.do 在Servlet 3.0中,通过Servlet容器初始化,可以自动完成Web对象的首次注册,因此可以省略这个步骤. API支持 容器初始化的核心是javax.servlet.ServletContainerInitializer接口,他只包含一个方法: ServletContainerInitializer 描述 void onStartup(Set > c, ServletContext ctx) Notifies this ServletContainerInitializer of the startup of the application represented by the given ServletContext. 在执行任何ServletContext监听器之前, 由Servlet容器自动调用onStartup()方法. 注意: 任何实现了ServletContainerInitializer的类必须使用@HandlesTypes注解标注, 以声明该初始化程序可以处理这些类型的类. 实例-SpringMVC初始化 利用Servlet容器初始化, SpringMVC可实现容器的零配置注册. SpringServletContainerInitializer @HandlesTypes(WebApplicationInitializer.class) public class SpringServletContainerInitializer implements ServletContainerInitializer { @Override public void onStartup(Set > webAppInitializerClasses, ServletContext servletContext) throws ServletException { List initializers = new LinkedList (); 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的实现 javax.servlet.ServletContainerInitializer 1 org.springframework.web.SpringServletContainerInitializer 元数据文件 javax.servlet.ServletContainerInitializer只有一行内容(即实现了 ServletContainerInitializer类的全限定名),该文本文件必须放在jar包的 META-INF/services目录下

猜你喜欢

转载自blog.51cto.com/13952955/2296045