GAE/J でファイルをアップロードする方法を学ぶ

Google App Engine(Java版)でファイルをアップロードするためにはどうすればいいのか?を色々試しながら勉強してみる。
まず、最もシンプルにこんなHTMLとサーブレットを書いてみる。

<html>
  <head></head>
  <body>
    <form action="upload" method="post" enctype="multipart/form-data">
      <input type="file" name="data"></td>
      <input type="submit"></td>
    </form>
  </body>
</html>
package hoge.fuga.piyo

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author sugyan
 */
@SuppressWarnings("serial")
public class UploadServlet extends HttpServlet {

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        resp.setContentType("text/plain");
        byte []buffer = new byte[req.getContentLength()];
        req.getInputStream().read(buffer);
        resp.getOutputStream().write(buffer);
    }
}

POSTされてきたデータをそのままbyte配列で吐き出すだけ。
ためしにテキストファイルをアップロードしてみる。

-----------------------------147409885318080705791348616203
Content-Disposition: form-data; name="data"; filename="hoge"
Content-Type: application/octet-stream

hoge

-----------------------------147409885318080705791348616203--

こういうものが出てくる。


ここから本当にPOSTしたファイルの中身だけを取り出すのは、標準のServletの機能としてはついていないらしい。
一般的にはApache CommonsのFileUploadライブラリを使うのがいいらしい。
FileUpload – Home
最新版をダウンロードしてjarをwar/WEB-INF/libに追加してBuild Pathに追加。


ServletFileUploadというクラスがあって、これのメソッドparseRequestはHttpServletRequestを受け取ってFileItemのListを返してくれる。ただしこのServletFileUploadにはFileItemFactoryをセットする必要がある。FileItemFactoryはインターフェースで、標準で実装されているクラスはDiskFileItemFactoryというクラスだけ。試しにこれを使ってみるとどうなるか。

import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;

...

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        boolean isMultipartContent = ServletFileUpload.isMultipartContent(req);
        if (isMultipartContent) {
            DiskFileItemFactory diskFileItemFactory = new DiskFileItemFactory();
            ServletFileUpload fileUpload = new ServletFileUpload(diskFileItemFactory);
            try {
                List<?> list = fileUpload.parseRequest(req);
            } catch (FileUploadException e) {
            }
        }
    }

で、実際にファイルをアップロードしてみると。

java.security.AccessControlException: access denied (java.io.FilePermission /tmp/upload_351ab641_12122ebbaba__8000_00000001.tmp write)
        at java.security.AccessControlContext.checkPermission(AccessControlContext.java:264)
        at java.security.AccessController.checkPermission(AccessController.java:427)
        at java.lang.SecurityManager.checkPermission(SecurityManager.java:532)
        at com.google.appengine.tools.development.DevAppServerFactory$CustomSecurityManager.checkPermission(DevAppServerFactory.java:76)
        at java.lang.SecurityManager.checkWrite(SecurityManager.java:962)
        at java.io.FileOutputStream.(FileOutputStream.java:169)
        at java.io.FileOutputStream.(FileOutputStream.java:131)
        at org.apache.commons.io.output.DeferredFileOutputStream.thresholdReached(DeferredFileOutputStream.java:165)
        at org.apache.commons.io.output.ThresholdingOutputStream.checkThreshold(ThresholdingOutputStream.java:221)
        at org.apache.commons.io.output.ThresholdingOutputStream.write(ThresholdingOutputStream.java:127)
        at org.apache.commons.fileupload.util.Streams.copy(Streams.java:101)
        at org.apache.commons.fileupload.util.Streams.copy(Streams.java:64)
...

と。access denied。GAEではファイルの書き込みを禁止されている。このDiskFileItemFactoryはどうやら/tmpの下にファイル書き込みをするらしいので、その操作が違反ということで弾かれてしまうらしい。


ではどうするか。
FileItemFactoryをimplementsするクラスを自作しよう。ファイルを書き込まないように作ってやれば良い。
FileItemFactoryの必須メソッドはcreateItemだけ。ただしそのreturn値はFileItemであり、これまたinterfaceなので、こいつも自前で実装する必要があるようだ。

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;

public class MyFileItemFactory implements FileItemFactory {

    public FileItem createItem(
            String fieldName, String contentType, boolean isFormField, String fileName) {
        return new MyFileItem(fieldName, contentType, isFormField, fileName);
    }
}

FileItemは必須メソッドが多い。とは言え全部しっかり書く必要は無い。重要なのはおそらくgetOutputStream。ここで受け取ったOutputStreamに対して、FactoryがFileItemにデータを書き込んでいくっぽい。
とりあえず以下のように書いてみた。

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;

import org.apache.commons.fileupload.FileItem;

@SuppressWarnings("serial")
public class MyFileItem implements FileItem {
    
    private String fieldName;
    private String contentType;
    private boolean isFormField;
    private String fileName;
    private ByteArrayOutputStream outputStream;
    
    public MyFileItem(
            String fieldName, String contentType, boolean isFormField, String fileName) {
        this.fieldName   = fieldName;
        this.contentType = contentType;
        this.isFormField = isFormField;
        this.fileName    = fileName;
    }

    public void delete() {
        outputStream = null;
    }

    public byte[] get() {
        if (outputStream == null) {
            return null;
        }
        return outputStream.toByteArray();
    }

    public String getContentType() {
        return contentType;
    }

    public String getFieldName() {
        return fieldName;
    }

    public InputStream getInputStream() throws IOException {
        return null;
    }

    public String getName() {
        return fileName;
    }

    public OutputStream getOutputStream() throws IOException {
        if (outputStream == null) {
            outputStream = new ByteArrayOutputStream();
        }
        return outputStream;
    }

    public long getSize() {
        return outputStream.size();
    }

    public String getString() {
        return new String(get());
    }

    public String getString(String arg0) throws UnsupportedEncodingException {
        return null;
    }

    public boolean isFormField() {
        return isFormField;
    }

    public boolean isInMemory() {
        return true;
    }

    public void setFieldName(String fieldName) {
        this.fieldName = fieldName;
    }

    public void setFormField(boolean isFormField) {
        this.isFormField = isFormField;
    }

    public void write(File file) throws Exception {
        // do not write
        return;
    }

}

fieldName, contentType, isFormField, fileNameの4つはコンストラクタで受け取ってそのままセットしたりゲットしたりするだけ。
getOutputStreamではByteArrayOutputStreamをnewして返してやる。するとここにデータが突っ込まれていくようなので、これを使って例えばgetSizeではoutputStream.size()を返してやればサイズが取得できるようになるし、getではoutputStream.toByteArray()を返してやれば良い。
あとはまぁgetStringとか使いたければ実装してやればいいし、使わないと思うものは実装しないで放っておけば良いと思う。isFormFieldはファイル送信したものとただのParameterと区別するために使われるっぽい。isInMemoryは何に使うのかよく分からなかった。


上記2つのクラスを実装してやれば、GAE上でもcommons-fileuploadを使ってファイルのアップロードができるようになるはず。
例えばアップロードされたデータをそのまま返す、という操作をこんなカンジで。

    FileItemFactory factory = new MyFileItemFactory();
    ServletFileUpload fileUpload = new ServletFileUpload(factory);
    FileItem fileItem = null;
    try {
        List<?> fileItems = fileUpload.parseRequest(req);
        if (fileItems.size() == 1) {
            fileItem = (FileItem)fileItems.get(0);
        }
    } catch (FileUploadException e) {
    }
    resp.setContentType(fileItem.getContentType());
    resp.getOutputStream().write(fileItem.get());

追記

今さらこんなのを見つけた orz
http://code.google.com/intl/ja-JP/appengine/kb/java.html#fileforms
実装クラスなんて作らなくてもできるのか… あとで試してみよう